diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml index 62f41f9e6f..85cccf7afa 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml @@ -9,8 +9,8 @@ en: f96bf1ebc0719fb0 zh: e9a1144f0e76572d /agent-note-background-hover-and-screenshot-coordinates/decision: - en: 0cdd295d5f1872ed - zh: fe03ec9f7df94a4a + en: 0f45528ea6c5988c + zh: 9bde9df417ddb1ce /agent-note-background-hover-and-screenshot-coordinates/alternatives-considered: en: 5fc4beaf79761c50 zh: 4948333b0020be28 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md index e43121c8c8..d81d964f8a 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md @@ -12,7 +12,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer. Native hover first uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route falls back to the real system pointer. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). -Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 1000 for click, hover, scroll and drag; it does not apply to AX indices. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. +Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for click, hover, scroll and drag; it does not apply to AX indices. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. `LlmAdapter.imageRequestTarget` exposes the same dimensions and byte target used by image serialization. DeepSeek and pi-ai share their existing projection functions with this method. Other adapters preserve normalized images unless they declare resizing. Screenshot bytes returned to programs match the request image; its durable source is admitted and stored once. The native PNG metadata is checked against decoded source dimensions before any input mapping is published. diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md index 51beb3967f..2ef7ac9e44 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md @@ -12,7 +12,7 @@ Status: implemented 原生悬停优先寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时回退到真实系统鼠标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 -面向模型的坐标默认使用返回截图中的像素。click、hover、scroll 和 drag 传入 `relative: true` 时接受 0~1000 的千分比整数,不适用于 AX 索引。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 +面向模型的坐标默认使用返回截图中的像素。click、hover、scroll 和 drag 传入 `relative: true` 时接受 0~999 的千分比整数,不适用于 AX 索引。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 `LlmAdapter.imageRequestTarget` 提供与图片序列化相同的尺寸和字节目标。DeepSeek 和 pi-ai 在该方法中复用已有投影函数。其他适配器除非声明缩放,否则保留归一化图片。返回程序的截图字节与模型请求图片一致,持久原图只接入并保存一次。发布输入映射前,原生 PNG 元数据必须与解码后的原图尺寸一致。 diff --git a/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.i18n.yaml new file mode 100644 index 0000000000..efb7b7a757 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-08-26-web-rich-text-quotes.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.md +/agent-note-durable-rich-text-quotes-in-the-web-composer: + en: 49522a0a81db6981 + zh: 92e81872377e96e9 +/agent-note-durable-rich-text-quotes-in-the-web-composer/problem: + en: 7bd295c086ad9905 + zh: 704dc665acfaa5ec +/agent-note-durable-rich-text-quotes-in-the-web-composer/decision: + en: d27134e46ab55ea0 + zh: 3f1da063b661a1b3 +/agent-note-durable-rich-text-quotes-in-the-web-composer/alternatives-considered: + en: 1999da9f900c9be3 + zh: cf6aad990a180851 +/agent-note-durable-rich-text-quotes-in-the-web-composer/verification: + en: b320c262d9c08ee4 + zh: 6047f864213a2b4c +/agent-note-durable-rich-text-quotes-in-the-web-composer/consequences: + en: bb6f4338aad0ec41 + zh: 93a4396b619492b1 diff --git a/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.md b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.md new file mode 100644 index 0000000000..c122d6c671 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.md @@ -0,0 +1,37 @@ +# Agent Note: Durable rich-text quotes in the Web composer + +Status: implemented + +English | [中文](2026-08-26-web-rich-text-quotes.zh.md) + +## Problem + +Selected transcript excerpts need atomic editing and reload-safe drafts without depending on the availability of their source messages. + +## Decision + +`ui-conversation` owns an inline `QuoteNode` beside reference nodes in the existing editor runtime. Its clipboard projection is canonical Markdown, while its detect projection is one atomic character. The existing semantic `DraftSnapshot` persists quote text and clipboard-coordinate spans beside references; raw Lexical JSON and runtime node identities remain outside browser storage. Session switching, Workspace transfer, and failed-send restoration use this same snapshot. Quote controls receive runtime-only locale labels. + +`ui-chat` accepts one nonblank browser selection within displayed user content, including pending steering and local submission echoes or finalized Assistant text and reasoning. Streaming and interrupted Assistant output, tools, injected context, and Markdown controls are excluded. Selection extraction follows visible line and block breaks and inserts through the current Session input actions. + +Text interaction belongs to the shared primitive and the Chat-owned list Slot. Quote registers only gesture admission and insertion. Local absolute placement keeps actions in the content coordinate system; outside presses and Escape close the same area-owned interaction for every consumer. Dismissal and selection clearing are separate operations so a failed insertion preserves the excerpt. Context-menu consumers cancel their originating browser event synchronously; rendering a Slot cannot retroactively cancel it. + +The [text-derived blockquote decision](../simplification/2026-10-13-text-derived-message-quotes.md) owns sent-message recognition and presentation. The [UserText evaluator](../architecture/2026-10-10-user-text-json-nodes.md) renders derived quote nodes through the existing matcher registry. + +Quotes display a compact single-line preview; clipped excerpts expose the complete text in a shared inline HoverCard. Their text has no source-navigation action. The editable draft alone exposes whole-node removal. Only selected text survives message persistence: no source Session, message, or selection coordinates are retained. + +## Alternatives considered + +**Persist Lexical JSON or introduce another draft format.** Editor serialization would become a product storage obligation, while a second draft format would duplicate restoration and transfer ownership. The existing semantic snapshot already carries reference spans and extends to quotes. + +**Retain source navigation.** Availability checks would require source coordinates, subscriptions, history pagination, focus routing, and failure states. A future navigation feature must define those durable coordinates and target-owned actions before adding them. + +**Store UserText nodes or callbacks.** Text nodes are derived presentation, and callbacks are runtime state. Both can change independently of the logged input and semantic draft. + +## Verification + +Composer tests cover atomic removal and undo, semantic storage round trips, Markdown serialization, and failed-send restoration. UserText tests cover text recognition, literal excerpt rendering, literal references, and credential spelling. The keyless Web scenario exercises selection, clipped previews, draft switching and reload, persistence, and ordinary Markdown submission through the shipped composition. + +## Consequences + +The draft and Session retain enough data for atomic editing and replay without retaining source-history dependencies. UserText remains a display projection; its JSON nodes never replace authoritative content. Browser-local storage retains unsent selected text under the existing draft policy. diff --git a/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.zh.md b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.zh.md new file mode 100644 index 0000000000..7f324768e1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.zh.md @@ -0,0 +1,37 @@ +# Agent Note:Web composer 中的持久富文本 quote + +Status: implemented + +[English](2026-08-26-web-rich-text-quotes.md) | 中文 + +## 问题 + +所选 transcript 摘录需要原子编辑与可跨刷新恢复的草稿,不依赖来源消息的可用性。 + +## 决策 + +`ui-conversation` 在现有编辑器 runtime 中于 reference 节点旁拥有行内 `QuoteNode`。其剪贴板投影是规范 Markdown,检测投影则是一个原子字符。现有语义 `DraftSnapshot` 在 reference 旁持久化 quote 文本与剪贴板坐标范围;原始 Lexical JSON 与 runtime 节点身份不进入浏览器存储。Session 切换、Workspace 转移和发送失败恢复使用同一个 snapshot。Quote 控件接收仅用于 runtime 的 locale 标签。 + +`ui-chat` 接受已展示的用户内容(包括待接纳 steering 和本地发送回显)、或最终完成的 Assistant 文本与 reasoning 中的一个非空浏览器选区。流式及中断的 Assistant 输出、tool、注入上下文和 Markdown 控件被排除。选区提取遵循可见的行与 block 换行,通过当前 Session 的 input actions 插入。 + +文本交互属于共享基础能力与 Chat 拥有的 list Slot,quote 只注册手势接管与插入操作。本地绝对定位让操作处于内容坐标系;外部按下与 Escape 对所有使用方关闭同一个区域拥有的交互。关闭展示与清除选区是独立操作,因此插入失败时保留摘录。右键菜单使用方同步取消来源浏览器事件;渲染 Slot 无法事后取消事件。 + +[文本派生 blockquote 决策](../simplification/2026-10-13-text-derived-message-quotes.zh.md)拥有发送后的消息识别与展示。[UserText 计算器](../architecture/2026-10-10-user-text-json-nodes.zh.md)通过现有 matcher registry 渲染派生的 quote 节点。 + +Quote 显示紧凑的单行预览;截断的摘录通过共享行内 HoverCard 展示完整文本。文本不提供来源导航操作,只有可编辑草稿提供整节点删除。消息持久化只保留所选文本:不保留来源 Session、消息或选区坐标。 + +## 考虑过的替代方案 + +**持久化 Lexical JSON 或引入另一套草稿格式。** 编辑器序列化会成为产品存储义务,第二套草稿格式则会重复拥有恢复与转移逻辑。现有语义 snapshot 已携带 reference 范围,也可扩展到 quote。 + +**保留来源导航。** 可用性检查需要来源坐标、订阅、历史分页、焦点路由和失败状态。未来若添加导航功能,必须先定义其持久坐标与目标拥有的操作。 + +**存储 UserText 节点或 callback。** 文本节点是派生的展示,callback 是 runtime 状态;二者都可以独立于记录的输入和语义草稿变化。 + +## 验证 + +Composer 测试覆盖原子删除与撤销、语义存储往返、Markdown 序列化和发送失败恢复。UserText 测试覆盖文本识别、字面摘录展示、字面 reference 与凭据原文。无密钥 Web 场景通过已发布组合验证选区、截断预览、草稿切换与刷新、持久化和普通 Markdown 提交。 + +## 后果 + +草稿与 Session 保留足以支持原子编辑和 replay 的数据,不保留对来源历史的依赖。UserText 仍是展示投影;其 JSON 节点不会替代权威 content。浏览器本地存储按现有草稿策略保留未发送的所选文本。 diff --git a/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.i18n.yaml new file mode 100644 index 0000000000..0b25c139c2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-13-text-derived-message-quotes.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.md +/agent-note-recognize-message-quotes-from-markdown-text: + en: a2abd7f693937d51 + zh: f9e4bb817536dbbe +/agent-note-recognize-message-quotes-from-markdown-text/problem: + en: d71ebbacbd9bd8a3 + zh: 45c1f7fd1d797f61 +/agent-note-recognize-message-quotes-from-markdown-text/decision: + en: 0abb09d5ba396f69 + zh: dd067b0ca5fd376f +/agent-note-recognize-message-quotes-from-markdown-text/alternatives-considered: + en: 05c6009796927583 + zh: 9f08229767c07b31 +/agent-note-recognize-message-quotes-from-markdown-text/verification: + en: 790598fd0f3f0c67 + zh: 0399e0e322ffca40 +/agent-note-recognize-message-quotes-from-markdown-text/consequences: + en: 21f96c04f6edcc1d + zh: 8ea3f42a8e981592 diff --git a/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.md b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.md new file mode 100644 index 0000000000..2a6ecaa833 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.md @@ -0,0 +1,29 @@ +# Agent Note: Recognize message quotes from Markdown text + +Status: implemented + +English | [中文](2026-10-13-text-derived-message-quotes.zh.md) + +## Problem + +Quote-only content indexes duplicate information in Markdown text and require coordinated prompt, source, echo, and queue metadata. The same visible blockquote can acquire different presentation and direct-input behavior depending on how it was created. + +## Decision + +Messages contain ordinary Markdown text. The shared blockquote matcher recognizes consecutive explicitly marked lines for UserText presentation. Hand-written and composer-created blockquotes have identical sent-message behavior. Quote previews display decoded text without running UserText rules over their contents. Session-reference and skill processing applies normally to quoted text; recognized Session labels retain the same presentation as the surrounding quote text. + +The [rich-text quote feature](../feature/2026-08-26-web-rich-text-quotes.md) retains atomic draft nodes and semantic restoration spans. This decision supersedes only its durable quote-index distinction. UserText nodes and recognition ranges remain derived values. + +## Alternatives considered + +**Keep quote-only indexes.** Indexes preserve a distinction between typed and inserted quotes, but that distinction is unnecessary for sent messages and requires metadata coordination across every producer and renderer. + +**Remove atomic draft spans.** Plain text cannot restore whole-node editing, undo, Session switching, or failed-send restoration. Draft spans remain browser-owned editing state. + +## Verification + +Matcher tests cover original offsets, explicit line markers, blank excerpts, and literal nested quotes. Composer tests assert ordinary text submission. The shipped Web scenario checks text-only persistence and quote display after reload. + +## Consequences + +Messages need no quote fields or content indexes. Typed blockquotes also display as quotes without changing direct-input processing. Recognition deliberately covers explicit quote lines rather than Markdown lazy continuation or fenced-code parsing. Source navigation still requires a separate durable-coordinate design. diff --git a/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.zh.md b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.zh.md new file mode 100644 index 0000000000..c9ee20f906 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 从 Markdown 文本识别消息 quote + +Status: implemented + +[English](2026-10-13-text-derived-message-quotes.md) | 中文 + +## 问题 + +纯 quote content 索引重复了 Markdown 文本中的信息,并要求 prompt、source、回显与队列协同携带元数据。同样可见的 blockquote 会因创建方式不同而获得不同的展示与直接输入行为。 + +## 决策 + +消息包含普通 Markdown 文本。共享 blockquote 匹配器识别连续的显式标记行,用于 UserText 展示。手写与输入框创建的 blockquote 在发送后具有相同行为。Quote 预览直接展示还原后的文本,不对正文再次运行 UserText 规则。Session-reference 与 skill 处理照常作用于引用文本;已识别的 Session 标签与周围引用文字保持相同样式。 + +[富文本 quote 功能](../feature/2026-08-26-web-rich-text-quotes.zh.md)保留原子草稿节点与语义恢复范围。本决策仅取代其持久 quote 索引区分。UserText 节点与识别范围仍是派生值。 + +## 考虑过的替代方案 + +**保留纯 quote 索引。** 索引保留手写与插入 quote 的区别,但发送后的消息无需这一区分,且要求每个生产方与渲染器协同处理元数据。 + +**移除原子草稿范围。** 纯文本无法恢复整节点编辑、撤销、Session 切换或发送失败恢复。草稿范围仍是浏览器拥有的编辑状态。 + +## 验证 + +匹配器测试覆盖原始偏移、显式行标记、空白摘录与字面的嵌套 quote。Composer 测试断言普通文本提交。已发布 Web 场景检查仅文本的持久化与刷新后的 quote 展示。 + +## 后果 + +消息无需 quote 字段或 content 索引。手写 blockquote 也显示为 quote,不改变直接输入处理。识别有意限定为显式引用行,不涵盖 Markdown 懒惰续行或代码围栏解析。来源导航仍需独立设计持久坐标。 diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index bb8347823b..338ff29a21 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. - **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. -- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. +- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. English UI copy, including plugin `meta.title` and `meta.description`, uses sentence case: capitalize only the first word, proper nouns, and product names such as `Chrome DevTools`, `Git`, or `MCP` (`Git worktrees`, not `Git Worktrees`). Chinese copy keeps its own conventions. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. - **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only. diff --git a/AGENTS.md b/AGENTS.md index 1f88aa1293..69dbd7d69e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Official plugin artwork:** use [dsh-official-plugin-artwork](.agents/skills/dsh-official-plugin-artwork/SKILL.md) for official and official experimental plugin icons; obtain designer approval before landing new glyphs. -- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). +- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). English copy, including plugin titles, uses sentence case ([rule](.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 43b8d64815..c349501785 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -78,6 +78,7 @@ External packages installed for runtime use or embedded in distributed Host or b | [`ajv`](https://github.com/ajv-validator/ajv) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`big.js`](https://github.com/MikeMcl/big.js) | MIT | +| [`birpc`](https://github.com/antfu-collective/birpc) | MIT | | [`buffer`](https://github.com/feross/buffer) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause | diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index c84912a9f1..a8c1850472 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -5,7 +5,7 @@ import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' -import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const overlayPath = process.argv[2] if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path') @@ -31,6 +31,7 @@ try { id: agentId, options: {}, session, + model: unsupportedModel(), inbox: unsupportedInbox(), status: 'idle', send: () => {}, @@ -43,7 +44,7 @@ try { } const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, signal: new AbortController().signal }, + { modelSelection: { provider: 'mock', model: 'mock' }, messages: [], turn: 1, step: 1, signal: new AbortController().signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const catalog = decision.kind === 'enter' diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index bfcfc3ca75..d45d98dfd0 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -17,6 +17,7 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} {"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"}}} {"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"},"finishedAt":0,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}} +{"type":"model/selection","data":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cli-smoke-call"},"toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false,"id":"{{sessionId}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} diff --git a/apps/web/package.json b/apps/web/package.json index 90e6296c65..8fc868a93f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -72,5 +72,8 @@ "@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-worktree": "workspace:*", "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" + }, + "dependencies": { + "@deepseek-ai/dsh-message-quotes": "workspace:*" } } diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index bd7dcff720..d5f699a288 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -85,8 +85,8 @@ describe('web e2e: Agent Teams panel', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections') onTestFinished(() => { projectionReads.mockRestore() }) - await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() - const action = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + await page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }).click() + const action = page.getByRole('dialog', { name: 'Agent team', exact: true }) await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor() await action.getByText('lead').waitFor() expect(projectionReads).not.toHaveBeenCalled() @@ -119,8 +119,8 @@ describe('web e2e: Agent Teams panel', () => { it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel-keyboard')) - const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) - const trigger = page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }) + const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) const viewport = page.viewportSize()! await trigger.click() await panel.getByText('lead', { exact: true }).waitFor() @@ -162,8 +162,8 @@ describe('web e2e: Agent Teams panel', () => { }) it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => { - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true }) - const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) + const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) await trigger.hover() await panel.waitFor() await panel.hover() @@ -182,8 +182,8 @@ describe('web e2e: Agent Teams panel', () => { writeScopes: [], }) const viewport = page.viewportSize()! - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true }) - const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) + const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) const card = panel.locator('article').filter({ hasText: 'Responsive task' }) await trigger.click() await card.waitFor() diff --git a/apps/web/tests/caption-overlays.e2e.ts b/apps/web/tests/caption-overlays.e2e.ts index d0713573c2..2a86277843 100644 --- a/apps/web/tests/caption-overlays.e2e.ts +++ b/apps/web/tests/caption-overlays.e2e.ts @@ -26,6 +26,10 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro await openSettings(page, 'zh') const settings = page.getByRole('dialog', { name: '设置', exact: true }) const settingsMask = settings.locator('..').locator(':scope > [aria-hidden="true"]') + const permissionText = settings.getByText('权限', { exact: true }).locator('..') + expect(await permissionText.evaluate(el => getComputedStyle(el).paddingRight)).toBe('48px') + const cubes = settings.getByRole('button', { name: /浅色|深色|跟随系统/ }) + expect(await cubes.first().evaluate(el => getComputedStyle(el).flexBasis)).toBe('180px') for (const fullscreen of [false, true, false]) { await page.evaluate(value => document.documentElement.toggleAttribute('data-fullscreen', value), fullscreen) const margin = fullscreen ? 24 : 60 @@ -86,8 +90,10 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro }, platform) if (await reopenSidebar.isVisible()) await reopenSidebar.click() await openSettings(page, 'zh') - await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 24) + await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 0) expect((await settingsMask.boundingBox())?.y).toBe(0) + expect(await permissionText.evaluate(el => getComputedStyle(el).paddingRight)).toBe(platform === 'darwin' ? '48px' : '16px') + expect(await cubes.first().evaluate(el => getComputedStyle(el).flexBasis)).toBe(platform === 'darwin' ? '180px' : '0px') await page.keyboard.press('Escape') await settings.waitFor({ state: 'hidden' }) } diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index 8b1309c725..43ba40e4e0 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -80,15 +80,15 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr expect(await card.getByRole('button', { name: /Accessibility permission|Screen Recording permission/ }).count()).toBe(0) await page.setViewportSize({ width: 1680, height: 600 }) const plugins = page.locator('[data-plugin-panel]') - const openDetails = page.getByRole('button', { name: 'View Computer Use', exact: true }) + const openDetails = page.getByRole('button', { name: 'View Computer use', exact: true }) await openDetails.scrollIntoViewIfNeeded() expect(await plugins.evaluate(element => element.scrollTop)).toBeGreaterThan(0) await openDetails.click() const panel = page.locator('[data-cua-settings]') const accessibility = panel.getByRole('group', { name: 'Accessibility', exact: true }) await accessibility.getByRole('button', { name: 'Request permission', exact: true }).waitFor() - expect(await panel.getByRole('region', { name: 'Authorize Computer Use', exact: true }).getByRole('group').count()).toBe(4) - expect(await panel.getByRole('region', { name: 'Manage applications Computer Use can access', exact: true }).count()).toBe(1) + expect(await panel.getByRole('region', { name: 'Authorize computer use', exact: true }).getByRole('group').count()).toBe(4) + expect(await panel.getByRole('region', { name: 'Manage applications computer use can access', exact: true }).count()).toBe(1) await expect.poll(() => panel.locator('li img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1) expect(await panel.getByText('/fixture/app', { exact: true }).count()).toBe(0) expect(await accessibility.getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1) @@ -186,7 +186,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr return { errors, layouts, documents, stop: () => { observer.disconnect(); cancelAnimationFrame(frame) } } }) try { - const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer Use', exact: true }) + const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer use', exact: true }) for (const width of [1680, 390]) { await page.setViewportSize({ width, height: 1000 }) if (width === 390) await page.locator('[data-sidebar-collapsed]').waitFor({ state: 'attached' }) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 225cb37062..dffc30778d 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -95,6 +95,8 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default')) await page.keyboard.press('Escape') await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const patchPath = join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') + expect(await readFile(patchPath, 'utf8')).toContain('model: deepseek-v4-flash') await page.reload({ waitUntil: 'load' }) const input = page.locator('[data-composer-input]').first() const trigger = page.getByRole('button', { name: '请选择模型', exact: true }) @@ -109,8 +111,8 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ provider: 'deepseek-official', model: 'deepseek-flash', }) - const settings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') - expect(settings).toContain('provider: deepseek-official') + const settings = await readFile(patchPath, 'utf8') + expect(settings).not.toContain('id: agent-default-model') expect(tripwire.pageErrors).toEqual([]) }, 60_000) }) diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index dd8628cd57..3b5ebf1b20 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/cua-permissions/application-approval.expected.md b/apps/web/tests/expected/cua-permissions/application-approval.expected.md index 96f5eb74e3..fc9007a938 100644 --- a/apps/web/tests/expected/cua-permissions/application-approval.expected.md +++ b/apps/web/tests/expected/cua-permissions/application-approval.expected.md @@ -1,8 +1,8 @@ - text: Waiting for approval - group "Approval details": - strong: Fixture - - text: Allow Computer Use to launch, read, capture and control this application? - - paragraph: Session access lasts until DSH restarts or Computer Use is disabled. + - text: Allow computer use to launch, read, capture and control this application? + - paragraph: Session access lasts until DSH restarts or computer use is disabled. - button "Reject" - button "Allow for this session" - button "Always allow" diff --git a/apps/web/tests/expected/cua-permissions/missing.expected.md b/apps/web/tests/expected/cua-permissions/missing.expected.md index 8dfba492a2..cb4ade57ac 100644 --- a/apps/web/tests/expected/cua-permissions/missing.expected.md +++ b/apps/web/tests/expected/cua-permissions/missing.expected.md @@ -1,8 +1,8 @@ -- region "Authorize Computer Use": - - heading "Authorize Computer Use" [level=3] +- region "Authorize computer use": + - heading "Authorize computer use" [level=3] - group "Default mode for new sessions": - strong: Default mode for new sessions - - paragraph: Computer Use is available only in PTC mode. + - paragraph: Computer use is available only in PTC mode. - button "Set to PTC" - group "PTC execution mode": - strong: PTC execution mode @@ -17,8 +17,8 @@ - paragraph: In System Settings, allow Visual Studio Code.app to capture application windows. - button "Request permission" - paragraph: Manage permissions in System Settings for the application named by macOS. -- region "Manage applications Computer Use can access": - - heading "Manage applications Computer Use can access" [level=3] +- region "Manage applications computer use can access": + - heading "Manage applications computer use can access" [level=3] - heading "Always-allowed applications" [level=4] - list: - listitem: diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index abd17a0782..dc532d598d 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index 84a652e494..f657109584 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index 865390f104..885ad3b4f3 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -1,6 +1,6 @@ - dialog "Settings": + - text: Settings - navigation: - - text: Settings - button "General" - button "Models" - button "Built-in plugins" diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md index 84f93fc533..c53b1d766f 100644 --- a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index de6a45d25c..9015847ef4 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index 0380871342..063bbf1550 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 73e8fc9a47..f2aa8d44b9 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index 269d5ebd35..27749f1ca2 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index 0561d65614..dd808d7f86 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index e0b4eb62cf..07d8f4c169 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/models-settings/native-keyless.expected.md b/apps/web/tests/expected/models-settings/native-keyless.expected.md index e8ae4de994..339655dde8 100644 --- a/apps/web/tests/expected/models-settings/native-keyless.expected.md +++ b/apps/web/tests/expected/models-settings/native-keyless.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/models-settings/refreshing.expected.md b/apps/web/tests/expected/models-settings/refreshing.expected.md index ad9ac8cda1..52e4eb8439 100644 --- a/apps/web/tests/expected/models-settings/refreshing.expected.md +++ b/apps/web/tests/expected/models-settings/refreshing.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index cff2fd1b37..312a5cf8d9 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 35771c887a..f64f3276aa 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/onboarding-native/models.expected.md b/apps/web/tests/expected/onboarding-native/models.expected.md index 317a184c37..3d1f292685 100644 --- a/apps/web/tests/expected/onboarding-native/models.expected.md +++ b/apps/web/tests/expected/onboarding-native/models.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index ad970ad588..15dfde8f44 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index e36458f98a..a51f5a125c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -1,6 +1,6 @@ - dialog "Settings": + - text: Settings - navigation: - - text: Settings - button "General" - button "Models" - button "Built-in plugins" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index e36458f98a..a51f5a125c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -1,6 +1,6 @@ - dialog "Settings": + - text: Settings - navigation: - - text: Settings - button "General" - button "Models" - button "Built-in plugins" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 9f9c6ee85e..9288e7fb27 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -1,6 +1,6 @@ - dialog "设置": + - text: 设置 - navigation: - - text: 设置 - button "通用设置" - button "模型" - button "内置插件" diff --git a/apps/web/tests/expected/shared-inspector/metadata-en.expected.md b/apps/web/tests/expected/shared-inspector/metadata-en.expected.md index 4953cbfc60..8a700d7178 100644 --- a/apps/web/tests/expected/shared-inspector/metadata-en.expected.md +++ b/apps/web/tests/expected/shared-inspector/metadata-en.expected.md @@ -1,9 +1,9 @@ -- bundle: Developer Tools +- bundle: Developer tools description: Inspect and debug raw session data, grouped chat messages, and the NodeJS backend. -- component: NodeJS Inspector +- component: NodeJS inspector description: Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging. -- component: Session Log +- component: Session log description: Enable analysis of the current session's raw data and grouped chat messages in the session sidebar. -- session sidebar: Session Log +- session sidebar: Session log description: Analyze raw logs and grouped chat messages for the current session in the sidebar. -- NodeJS bottom: NodeJS Inspector +- NodeJS bottom: NodeJS inspector diff --git a/apps/web/tests/inspector-late-enable.e2e.ts b/apps/web/tests/inspector-late-enable.e2e.ts index 9a7cda3eee..c8c7e6fff3 100644 --- a/apps/web/tests/inspector-late-enable.e2e.ts +++ b/apps/web/tests/inspector-late-enable.e2e.ts @@ -35,7 +35,7 @@ it('opens bottom DevTools after the Developer Tools bundle is enabled on an alre await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() const plugins = page.locator('[data-plugin-panel]') - const toggle = plugins.getByRole('switch', { name: 'Enable Developer Tools', exact: true }) + const toggle = plugins.getByRole('switch', { name: 'Enable Developer tools', exact: true }) await toggle.waitFor() expect(await toggle.getAttribute('aria-checked')).toBe('false') const panel = page.locator('[data-inspector-panel]') diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 4523b55b3b..21826685e8 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -923,14 +923,14 @@ describe('web e2e: plugin manager', () => { expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1) try { await setLanguage('en') - await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor() + await panel.getByRole('heading', { name: 'Agent teams', exact: true }).waitFor() expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1) - await child.getByText('Team Tools', { exact: true }).waitFor() + await child.getByText('Team tools', { exact: true }).waitFor() expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() - await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor() - expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1) + await panel.getByRole('button', { name: 'View Agent teams', exact: true }).waitFor() + expect(await panel.getByRole('switch', { name: 'Enable Agent teams', exact: true }).count()).toBe(1) // Schedule ships in the delivered composition, so no bundle card carries it in either language. expect(await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).count()).toBe(0) // The official configuration pages follow the language too, from their own dictionary. diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index d4d502e388..4cb8a8946f 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -776,7 +776,7 @@ async function openSessionInspector(page: Page): Promise { // The showcase opens with a file tab in the right sidebar; a new tab shows the guide entries. await page.getByRole('button', { name: 'New tab', exact: true }).click() await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click({ timeout: 30_000 }) - await page.getByRole('table', { name: 'Raw Log', exact: true }).waitFor({ timeout: 15_000 }) + await page.getByRole('table', { name: 'Raw log', exact: true }).waitFor({ timeout: 15_000 }) expect(await setBundle(page, INSPECTOR_BUNDLE, false)).toBe('applied') } diff --git a/apps/web/tests/rich-text-quotes.e2e.ts b/apps/web/tests/rich-text-quotes.e2e.ts new file mode 100644 index 0000000000..ba7b5e0c7c --- /dev/null +++ b/apps/web/tests/rich-text-quotes.e2e.ts @@ -0,0 +1,646 @@ +/** Keyless real-Web coverage for quote selection, persistence, and uniform reference presentation. */ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import { SessionId } from '@deepseek-ai/dsh-session' +import { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' +import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, + parseSeedFixture, renderSeedFixture, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/rich-text-quotes', import.meta.url)) +const BORROWED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/rich-text-quotes/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SESSION_ID = 'rich-text-quotes-web-e2e' +const OTHER_ID = 'rich-text-quotes-other' +const LEGACY_DRAFT_KEY = `dsh.conversation.${SESSION_ID}` +const CURRENT_DRAFT_KEY = `dsh.conversation.${SESSION_ID}` +const LEGACY_DRAFT = JSON.stringify({ draft: 'Imported plain draft', view: 'chat', viewRequest: null }) +const SOURCE_TEXT = 'Durable source phrase selected from an earlier message and preserved as structured quote metadata.' +const THINKING_TEXT = 'Settled reasoning source' +const LITERAL_CREDENTIAL = 'sk-1234567890abcdefghijklmnopqrstuvwxyz' +const SESSION_MENTION = formatSessionReferenceMention({ sessionId: SessionId(OTHER_ID), label: 'Quote source' }) +const DIRECT_SYNTAX_TEXT = [ + `/quoted-demo ${SESSION_MENTION} ${LITERAL_CREDENTIAL} **literal Markdown**`, + ...Array.from({ length: 80 }, (_, index) => `Quote line ${String(index + 1)} contains preserved text.`), + 'End of long quote.', +].join('\n') +const SELECTED_SYNTAX_TEXT = DIRECT_SYNTAX_TEXT.replaceAll('\n', ' ') +const SENT_SYNTAX_TEXT = SELECTED_SYNTAX_TEXT.replace(SESSION_MENTION, '@Quote source') +const REPLY = 'Quote received.' + +async function quoteAppearance(quote: Locator): Promise> { + return quote.evaluate((element) => { + const style = getComputedStyle(element) + return { + alignItems: style.alignItems, + color: style.color, + display: style.display, + fontSize: style.fontSize, + gap: style.gap, + lineHeight: style.lineHeight, + margin: style.margin, + padding: style.padding, + } + }) +} + +const REPLAY: ReplayOverrideDoc = [{ + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: REPLY }, + { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }, + { type: 'usage', usage: { inputTokens: 128, outputTokens: 8 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], +}] + +function quoteHistoryFixture(raw: string): string { + const { headerLine } = parseSeedFixture(raw) + let seq = 0 + let time = 0 + const events: Array & { seq: number; time: number }> = [] + const at = (event: Record): number => { + const assigned = seq++ + events.push({ ...event, seq: assigned, time: time++ }) + return assigned + } + at({ type: 'turn/start', data: { turn: 1 } }) + at({ + type: 'user/message', + data: { + id: '{{message:1}}', + role: 'user', + content: [{ type: 'text', text: SOURCE_TEXT }], + source: { kind: 'user', rpcId: '{{rpc:1}}' }, + }, + surfaceOp: 'append', + }) + for (let ordinal = 2; ordinal <= 51; ordinal++) { + at({ + type: 'user/message', + data: { + id: `{{message:${String(ordinal)}}}`, + role: 'user', + content: [{ + type: 'text', + text: ordinal === 2 ? formatQuoteText(DIRECT_SYNTAX_TEXT) : `Filler message ${String(ordinal)}`, + }], + source: { kind: 'user', rpcId: `{{rpc:${String(ordinal)}}}` }, + }, + surfaceOp: 'append', + }) + } + at({ + type: 'user/message', + data: { + id: '{{message:52}}', + role: 'user', + content: [ + { type: 'text', text: `> ${SOURCE_TEXT}` }, + { type: 'text', text: '\n\nUse that excerpt.' }, + ], + source: { + kind: 'user', + rpcId: '{{rpc:52}}', + }, + }, + surfaceOp: 'append', + }) + at({ + type: 'session/title', + data: { + title: 'Quote history', + messageSeqs: Array.from({ length: 52 }, (_value, index) => index + 1), + source: { kind: 'fallback' }, + }, + }) + at({ type: 'step/start', data: { turn: 1, step: 1 } }) + at({ + type: 'assistant/message', + data: { + turn: 1, + step: 1, + message: { + id: '{{message:53}}', + role: 'assistant', + content: [ + { type: 'reasoning', text: THINKING_TEXT }, + { type: 'text', text: 'Quote fixture ready.' }, + ], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }, + stream: [ + { type: 'chunk', time: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }, + { type: 'reasoning-chunks', time0: 0, index: 0, dt: [], texts: [THINKING_TEXT] }, + { type: 'chunk', time: 0, chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: THINKING_TEXT } } }, + { type: 'chunk', time: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } }, + { type: 'text-chunks', time0: 0, index: 1, dt: [], texts: ['Quote fixture ready.'] }, + { type: 'chunk', time: 0, chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'Quote fixture ready.' } } }, + { type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } }, + ], + }, + surfaceOp: 'append', + }) + at({ type: 'step/end', data: { turn: 1, step: 1 } }) + at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }) + return renderSeedFixture(headerLine, events) +} + +function otherSessionFixture(raw: string): string { + const { headerLine } = parseSeedFixture(raw) + let seq = 0 + const events: Array & { seq: number; time: number }> = [] + const at = (event: Record): void => { + events.push({ ...event, seq, time: seq }) + seq += 1 + } + at({ type: 'turn/start', data: { turn: 1 } }) + at({ + type: 'user/message', surfaceOp: 'append', + data: { + id: '{{message:1}}', role: 'user', + content: [{ type: 'text', text: 'Other session.' }], + source: { kind: 'user', rpcId: '{{rpc:1}}' }, + }, + }) + at({ type: 'session/title', data: { title: 'Other session', messageSeqs: [1], source: { kind: 'fallback' } } }) + at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }) + return renderSeedFixture(headerLine, events) +} + +async function selectAllText(locator: Locator): Promise { + await locator.evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + document.dispatchEvent(new Event('selectionchange')) + }) +} + +async function expectEllipsizedQuote(box: Locator, text: Locator, container: Locator): Promise { + const [quoteBounds, containerBounds, presentation] = await Promise.all([ + box.boundingBox(), + container.boundingBox(), + text.evaluate((element) => { + const style = getComputedStyle(element) + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + overflow: style.overflow, + textOverflow: style.textOverflow, + whiteSpace: style.whiteSpace, + } + }), + ]) + expect(quoteBounds).not.toBeNull() + expect(containerBounds).not.toBeNull() + const expectedMaximum = Math.min(containerBounds!.width * 0.8, 300) + expect(quoteBounds!.width).toBeLessThanOrEqual(expectedMaximum + 1) + expect(presentation.overflow).toBe('hidden') + expect(presentation.textOverflow).toBe('ellipsis') + expect(presentation.whiteSpace).toBe('nowrap') + expect(presentation.scrollWidth).toBeGreaterThan(presentation.clientWidth) + return quoteBounds!.width +} + +async function openQuoteSession(page: Page): Promise { + await page.locator(`[data-row-key="session:${SESSION_ID}"]`).click() +} + +async function expectScrollableQuote(page: Page, quote: Locator, text: string): Promise { + await quote.hover() + const content = page.locator('[data-quote-popover]') + await expect.poll(() => content.textContent()).toBe(text) + const panel = content.locator('..') + await expect.poll(async () => { + const bounds = await panel.boundingBox() + return bounds !== null && bounds.y >= 8 && bounds.y + bounds.height <= page.viewportSize()!.height - 8 + }).toBe(true) + await panel.hover() + await page.mouse.wheel(0, 10_000) + await expect.poll(() => panel.evaluate(element => ({ + scrolled: element.scrollTop > 0, + atEnd: element.scrollTop + element.clientHeight >= element.scrollHeight - 1, + }))).toEqual({ scrolled: true, atEnd: true }) + expect(await content.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let last: Node | null = null + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) last = node + if (last === null) return false + const length = last.textContent?.length ?? 0 + const range = document.createRange() + range.setStart(last, length - 'End of long quote.'.length) + range.setEnd(last, length) + const end = range.getBoundingClientRect() + const panelBounds = element.parentElement!.getBoundingClientRect() + return end.top >= panelBounds.top && end.bottom <= panelBounds.bottom + })).toBe(true) + await page.mouse.move(0, 0) + await expect.poll(() => content.count()).toBe(0) +} + +async function crossQuoteGap(page: Page, quote: Locator, text: string, placement: 'above' | 'below'): Promise { + await quote.hover() + const content = page.locator('[data-quote-popover]') + await expect.poll(() => content.count()).toBe(1) + await expect.poll(() => content.textContent()).toBe(text) + const anchor = (await quote.locator('..').boundingBox())! + const panel = (await content.locator('..').boundingBox())! + expect(panel.width).toBeLessThan(300) + const above = panel.y < anchor.y + expect(above).toBe(placement === 'above') + const x = Math.max(anchor.x, panel.x) + Math.min(anchor.width, panel.width) / 2 + const y = above ? (panel.y + panel.height + anchor.y) / 2 : (anchor.y + anchor.height + panel.y) / 2 + expect(await page.evaluate(point => + Boolean(document.elementFromPoint(point.x, point.y)?.closest('[data-hovercard-bridge]')), { x, y })).toBe(true) + await page.mouse.move(x, y, { steps: 8 }) + await page.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) })) + await expect.poll(() => content.count()).toBe(1) + expect(await content.textContent()).toBe(text) + await content.hover() + const body = (await content.boundingBox())! + await page.mouse.move(body.x + 12, body.y + 15) + await page.mouse.down() + await page.mouse.move(body.x + 60, body.y + 15, { steps: 5 }) + await page.mouse.up() + expect(await page.evaluate(() => window.getSelection()?.toString().trim())).not.toBe('') + expect(await content.count()).toBe(1) + await page.keyboard.press('Escape') + await expect.poll(() => content.count()).toBe(0) +} + +describe.skipIf(MODE === 'record')('web e2e: rich-text quotes', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let replayDir: string + let tripwire: ReturnType + + beforeAll(async () => { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-rich-text-quotes-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(REPLAY)) + scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + }) + const skillDir = join(scaffold.workspaceCwd, '.agents', 'skills', 'quoted-demo') + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), [ + '---', + 'name: quoted-demo', + 'description: Example user-invoked skill', + 'disable-model-invocation: true', + '---', + '', + 'An example skill instruction.', + '', + ].join('\n')) + const borrowed = await readFile(await selectedSessionFixture(BORROWED), 'utf8') + const createdAt = Date.now() - 60_000 + await seedSession(scaffold, quoteHistoryFixture(borrowed), SESSION_ID, undefined, { createdAt }) + await seedSession(scaffold, otherSessionFixture(borrowed), OTHER_ID, undefined, { createdAt: createdAt + 1_000 }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.addInitScript(({ key, value }) => { + if (localStorage.getItem(key) === null) localStorage.setItem(key, value) + }, { key: LEGACY_DRAFT_KEY, value: LEGACY_DRAFT }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const group = page.getByRole('treeitem', { name: /Ungrouped/ }) + await group.waitFor({ timeout: 15_000 }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await openQuoteSession(page) + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'rich-text-quotes e2e cleanup failed') + }) + + it('renders and restores rich quotes with uniform styling after reference recognition', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-rich-text-quotes')) + const input = page.locator('[data-composer-input]').first() + await expect.poll(() => input.textContent()).toBe('Imported plain draft') + await input.click() + await input.press('ControlOrMeta+A') + await input.press('Backspace') + await expect.poll(() => input.textContent()).toBe('') + const sentQuote = page.locator('[data-message-bubble] [data-quote-preview]').filter({ hasText: SOURCE_TEXT }).first() + await sentQuote.waitFor({ timeout: 15_000 }) + await expect.poll(() => sentQuote.getAttribute('title')).toBeNull() + await expect.poll(() => sentQuote.getAttribute('role')).toBeNull() + await expect.poll(() => sentQuote.getAttribute('tabindex')).toBeNull() + await expect.poll(() => sentQuote.locator('xpath=ancestor::*[@data-message-bubble][1]').textContent()) + .toBe(`${SOURCE_TEXT} Use that excerpt.`) + const sentText = sentQuote.locator('[data-quote-preview-text]') + const sentWidth = await expectEllipsizedQuote( + sentQuote, + sentText, + sentQuote.locator('xpath=ancestor::*[@data-message-bubble][1]'), + ) + const sentAppearance = await quoteAppearance(sentQuote) + expect(sentAppearance.gap).toBe('4px') + expect(sentAppearance.margin).toBe('0px') + await sentQuote.hover() + const quotePopover = page.locator('[data-quote-popover]') + await expect.poll(() => quotePopover.textContent()).toBe(SOURCE_TEXT) + const hoveredSentBounds = await sentQuote.boundingBox() + expect(hoveredSentBounds).not.toBeNull() + expect(hoveredSentBounds!.width).toBeCloseTo(sentWidth, 0) + await quotePopover.hover() + // Exceed the pointer grace to prove the panel itself keeps the popover open. + await page.waitForTimeout(300) + await expect.poll(() => quotePopover.count()).toBe(1) + await page.mouse.move(0, 0) + await expect.poll(() => quotePopover.count()).toBe(0) + + const sourceBlocks = page.locator('[data-text-interaction-source]').filter({ hasText: SOURCE_TEXT }) + const olderSource = page.getByRole('button', { name: 'Load earlier' }) + if (await sourceBlocks.count() < 2 && await olderSource.isVisible()) await olderSource.click() + await expect.poll(() => sourceBlocks.count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2) + const source = sourceBlocks.filter({ hasNot: page.locator('[data-quote-preview]') }).first() + await source.waitFor() + + const quoteAction = page.getByRole('button', { name: 'Quote text', exact: true }) + await source.scrollIntoViewIfNeeded() + await source.dispatchEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', buttons: 1 }) + await selectAllText(source) + await expect.poll(() => quoteAction.count()).toBe(0) + await source.dispatchEvent('pointerup', { pointerId: 1, pointerType: 'mouse', buttons: 0 }) + await expect.poll(() => quoteAction.count()).toBe(1) + expect(await quoteAction.evaluate(element => element.closest('[data-chat-flow]') !== null)).toBe(true) + expect(await quoteAction.evaluate(element => getComputedStyle(element.closest('[data-text-interaction-surface]')!).position)).toBe('absolute') + await page.keyboard.press('Escape') + await expect.poll(() => quoteAction.count()).toBe(0) + await page.evaluate(() => window.dispatchEvent(new Event('resize'))) + await expect.poll(() => quoteAction.count()).toBe(0) + await source.dispatchEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 }) + await selectAllText(source) + await source.dispatchEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 0 }) + await expect.poll(() => quoteAction.count()).toBe(1) + await input.click() + await source.scrollIntoViewIfNeeded() + await expect.poll(() => quoteAction.count()).toBe(0) + await source.dispatchEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 }) + await selectAllText(source) + await source.dispatchEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 0 }) + await expect.poll(() => quoteAction.count()).toBe(1) + await page.locator('[data-conversation-scroll]').evaluate((element) => { + element.scrollTop = element.scrollHeight + element.dispatchEvent(new Event('scroll')) + }) + await expect.poll(() => quoteAction.isVisible()).toBe(false) + expect(await page.locator('[data-text-interaction-surface] button').count()).toBe(1) + await source.scrollIntoViewIfNeeded() + await expect.poll(() => quoteAction.isVisible()).toBe(true) + await quoteAction.click() + const draftQuote = page.locator('[data-composer-quote]') + const draftPreview = draftQuote.locator('[data-quote-preview]') + const draftPreviewText = draftPreview.locator('[data-quote-preview-text]') + await expect.poll(() => draftQuote.count()).toBe(1) + await expect.poll(() => draftQuote.textContent()).toContain(SOURCE_TEXT) + const draftText = draftPreviewText + await expect.poll(() => draftPreview.getAttribute('role')).toBeNull() + await expect.poll(() => draftPreview.getAttribute('tabindex')).toBeNull() + const draftWidth = await expectEllipsizedQuote( + draftPreview, + draftPreviewText, + input, + ) + expect(await quoteAppearance(draftPreview)).toEqual(sentAppearance) + await draftText.hover() + await expect.poll(() => quotePopover.textContent()).toBe(SOURCE_TEXT) + const hoveredDraftBounds = await draftPreview.boundingBox() + expect(hoveredDraftBounds).not.toBeNull() + expect(hoveredDraftBounds!.width).toBeCloseTo(draftWidth, 0) + await page.mouse.move(0, 0) + await expect.poll(() => quotePopover.count()).toBe(0) + await input.press('Backspace') + await expect.poll(() => draftQuote.count()).toBe(0) + await expect.poll(() => input.textContent()).toBe('') + await input.press('ControlOrMeta+z') + await expect.poll(() => draftQuote.count()).toBe(1) + + const removeQuote = draftQuote.getByRole('button', { name: 'Remove quote' }) + const removeIcons = removeQuote.locator('svg') + await expect.poll(() => removeIcons.first().evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await expect.poll(() => removeIcons.last().evaluate(element => getComputedStyle(element).opacity)).toBe('0') + await input.click() + await page.keyboard.press('ControlOrMeta+A') + await expect.poll(() => page.evaluate(() => window.getSelection()?.toString() ?? '')).toContain(SOURCE_TEXT) + await draftQuote.hover() + await expect.poll(() => removeIcons.first().evaluate(element => getComputedStyle(element).opacity)).toBe('0') + await expect.poll(() => removeIcons.last().evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await removeQuote.focus() + await expect.poll(() => removeIcons.last().evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await expectEllipsizedQuote(draftPreview, draftPreviewText, input) + await removeQuote.click() + await expect.poll(() => draftQuote.count()).toBe(0) + await expect.poll(() => input.textContent()).toBe('') + await expect.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed ?? true)).toBe(true) + await page.keyboard.press('ControlOrMeta+z') + await expect.poll(() => draftQuote.count()).toBe(1) + + await input.click() + await page.keyboard.press('ControlOrMeta+A') + await page.keyboard.press('ArrowRight') + await page.keyboard.press('Backspace') + await page.keyboard.press('Backspace') + await expect.poll(() => draftQuote.count()).toBe(0) + await page.keyboard.press('ControlOrMeta+z') + await expect.poll(() => draftQuote.count()).toBe(1) + await page.keyboard.press('ControlOrMeta+A') + await page.keyboard.press('ArrowLeft') + await page.keyboard.press('Delete') + await expect.poll(() => draftQuote.count()).toBe(0) + await page.keyboard.press('ControlOrMeta+z') + await expect.poll(() => draftQuote.count()).toBe(1) + + await page.locator(`[data-row-key="session:${OTHER_ID}"]`).click() + await openQuoteSession(page) + await expect.poll(() => draftQuote.count()).toBe(1) + + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const restoredQuote = page.locator('[data-composer-quote]') + await expect.poll(() => restoredQuote.count(), { timeout: 15_000 }).toBe(1) + const restoredPreview = restoredQuote.locator('[data-quote-preview]') + const restoredText = restoredQuote.locator('[data-quote-preview-text]') + await expect.poll(() => restoredPreview.getAttribute('role')).toBeNull() + await restoredText.click() + + await restoredQuote.getByRole('button', { name: 'Remove quote' }).click() + await expect.poll(() => input.textContent()).toBe('') + const process = page.locator('[data-turn-process]').first() + if (await process.getAttribute('aria-expanded') === 'false') await process.click() + await page.getByRole('button', { name: 'Analysis completed', exact: true }).click() + const think = page.getByRole('button', { name: /Think/u }) + await think.click() + const reasoningSource = page.locator('[data-text-interaction-source]').filter({ hasText: THINKING_TEXT }) + await reasoningSource.waitFor() + await selectAllText(reasoningSource) + await expect.poll(() => quoteAction.isVisible()).toBe(true) + const beforeComposerScroll = await reasoningSource.evaluate((element) => { + const scroller = element.closest('[data-conversation-scroll]')! + const composer = scroller.querySelector('[data-composer-seat]')! + const previous = scroller.scrollTop + scroller.scrollTop -= composer.getBoundingClientRect().top - element.getBoundingClientRect().top + 16 + return previous + }) + await expect.poll(() => reasoningSource.evaluate((element) => { + const composer = element.closest('[data-conversation-scroll]')!.querySelector('[data-composer-seat]')! + return element.getBoundingClientRect().top >= composer.getBoundingClientRect().top + })).toBe(true) + await expect.poll(() => quoteAction.isVisible()).toBe(false) + expect(await page.locator('[data-text-interaction-surface] button').count()).toBe(1) + await page.locator('[data-conversation-scroll]').evaluate((element, top) => { element.scrollTop = top }, beforeComposerScroll) + await expect.poll(() => quoteAction.isVisible()).toBe(true) + await quoteAction.click() + await expect.poll(() => draftQuote.textContent()).toContain(THINKING_TEXT) + await think.click() + await expect.poll(() => reasoningSource.isVisible()).toBe(false) + const reasoningQuote = draftQuote.locator('[data-quote-preview-text]') + await expect.poll(() => draftQuote.locator('[data-quote-preview]').getAttribute('role')).toBeNull() + await reasoningQuote.click() + await expect.poll(() => reasoningSource.isVisible()).toBe(false) + await expect.poll(() => think.getAttribute('aria-expanded')).toBe('false') + + const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true }) + if (await backToBottom.isVisible()) await backToBottom.click() + await expect.poll(() => backToBottom.isVisible()).toBe(false) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + + await draftQuote.getByRole('button', { name: 'Remove quote' }).click() + const loadEarlier = page.getByRole('button', { name: 'Load earlier' }) + if (await loadEarlier.isVisible()) await loadEarlier.click() + await page.getByText('Filler message 3', { exact: true }).waitFor({ timeout: 15_000 }) + await page.locator('[data-conversation-scroll]').evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll')) + }) + const syntaxSource = page.locator('[data-text-interaction-source]').filter({ hasText: DIRECT_SYNTAX_TEXT }).first() + await syntaxSource.waitFor({ timeout: 15_000 }) + await syntaxSource.scrollIntoViewIfNeeded() + await selectAllText(syntaxSource) + await quoteAction.click() + const viewport = page.viewportSize()! + try { + await expectScrollableQuote(page, draftPreview, SELECTED_SYNTAX_TEXT) + await page.setViewportSize({ width: 420, height: 420 }) + await expectScrollableQuote(page, draftPreview, SELECTED_SYNTAX_TEXT) + } finally { + await page.setViewportSize(viewport) + } + const beforeOffset = scaffold.ctx.agents.get(SessionId(SESSION_ID))!.session.seq + const settled = scaffold.whenTurnSettled() + await page.getByRole('button', { name: 'Send message' }).click() + const settledSessionId = await settled + await page.getByText(REPLY, { exact: true }).waitFor({ timeout: 20_000 }) + const longSentQuote = page.locator('[data-message-bubble] [data-quote-preview]').filter({ hasText: SENT_SYNTAX_TEXT }).last() + try { + await expectScrollableQuote(page, longSentQuote, SENT_SYNTAX_TEXT) + await page.setViewportSize({ width: 420, height: 420 }) + await expectScrollableQuote(page, longSentQuote, SENT_SYNTAX_TEXT) + } finally { + await page.setViewportSize(viewport) + } + expect(await longSentQuote.textContent()).toContain(LITERAL_CREDENTIAL) + const sentSource = longSentQuote.locator('[data-quote-preview-text]') + expect(await sentSource.textContent()).toBe(SENT_SYNTAX_TEXT) + expect(await sentSource.locator('*').count()).toBe(0) + const textAppearance = await quoteAppearance(sentSource) + const quoteStyle = await quoteAppearance(longSentQuote) + expect(textAppearance.color).toBe(quoteStyle.color) + expect(textAppearance.fontSize).toBe(quoteStyle.fontSize) + expect(textAppearance.lineHeight).toBe(quoteStyle.lineHeight) + expect(await longSentQuote.locator('[data-secret], [data-ref-chip], strong').count()).toBe(0) + const events = scaffold.ctx.agents.get(settledSessionId)!.session.snapshotEvents(beforeOffset) + expect(events.some(event => event.type === 'user/message' + && event.data.source.kind === 'skill-invocation')).toBe(true) + expect(events.some(event => event.type === 'user/message' + && event.data.source.kind === 'session-reference')).toBe(true) + const submitted = events.find(event => event.type === 'user/message' + && event.data.source.kind === 'user') + expect(submitted).toMatchObject({ + data: { + content: [{ type: 'text', text: formatQuoteText(SENT_SYNTAX_TEXT) }], + }, + }) + expect(submitted).not.toHaveProperty('data.source.quotes') + await expect.poll(() => input.textContent()).toBe('') + const persisted = await page.evaluate((key): unknown => JSON.parse(localStorage.getItem(key)!), CURRENT_DRAFT_KEY) + expect(persisted).toMatchObject({ draft: { text: '', references: [] } }) + await page.reload({ waitUntil: 'load' }) + await page.getByText(REPLY, { exact: true }).waitFor({ timeout: 20_000 }) + await expectScrollableQuote(page, longSentQuote, SENT_SYNTAX_TEXT) + const literalQuote = await captureStableAria( + page, `[data-message-bubble]:has([data-quote-preview-text]):has-text("${LITERAL_CREDENTIAL}") >> nth=-1`, scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\n\n## Literal quote body\n\n${literalQuote}`, MODE) + await expect.poll(() => input.textContent()).toBe('') + expect(await page.locator('[data-slot-error]').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 120_000) + + it('keeps adjacent quote previews exclusive while crossing their hover gaps', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-quote-hover-gap')) + const texts = ['First excerpt', 'Second excerpt'].map(line => Array.from({ length: 6 }, () => line).join('\n')) + const quoted = texts.map(formatQuoteText) + const separator = '\nx\n' + const draft = { + text: quoted[0]! + separator + quoted[1]!, references: [], + quotes: texts.map((text, index) => ({ + text, offset: index === 0 ? 0 : quoted[0]!.length + separator.length, length: quoted[index]!.length, + })), + } + const viewport = page.viewportSize()! + try { + await page.evaluate(({ key, draft }) => { + localStorage.setItem(key, JSON.stringify({ draft, view: 'chat', viewRequest: null })) + }, { key: CURRENT_DRAFT_KEY, draft }) + await page.reload({ waitUntil: 'load' }) + const draftQuotes = page.locator('[data-composer-quote] [data-quote-preview]') + await expect.poll(() => draftQuotes.count()).toBe(2) + await crossQuoteGap(page, draftQuotes.last(), texts[1]!, 'above') + const settled = scaffold.whenTurnSettled() + await page.getByRole('button', { name: 'Send message' }).click() + await settled + await page.setViewportSize({ width: 600, height: 1000 }) + const sentQuotes = page.locator('[data-message-bubble] [data-quote-preview]').filter({ hasText: 'First excerpt' }) + await sentQuotes.last().waitFor() + await sentQuotes.last().evaluate((element) => { element.scrollIntoView({ block: 'center' }) }) + await crossQuoteGap(page, sentQuotes.last(), texts[0]!, 'below') + expect(tripwire.pageErrors).toEqual([]) + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + } + }) + +}) diff --git a/apps/web/tests/schedule-phone.e2e.ts b/apps/web/tests/schedule-phone.e2e.ts new file mode 100644 index 0000000000..b28049ba3d --- /dev/null +++ b/apps/web/tests/schedule-phone.e2e.ts @@ -0,0 +1,130 @@ +// UI-only phone geometry for the Automation tasks page: no text field it owns +// sits under the 16px touch step, so the page never reads wider than the screen +// — iOS Safari zooms the visual viewport for a focused field below it. +import type { Browser, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-schedule' +import { launchWebScaffold, watchConsole } from './scaffold.ts' + +const PHONE_TASK_SESSION = SessionId('schedule-phone-web-e2e') +const PHONE_TASK_TITLE = 'Review overdue deployment' +const PHONE_PAGE_WIDTH = 390 + +/** The page's box against the screen, plus the font steps of its text fields. */ +interface ViewFacts { + width: number + documentWidth: number + smallestField: number + name: string + search: string + instruction: string +} + +/** Open the Automation tasks page and return its panel. */ +async function openTaskManager(page: Page, url: string): Promise { + await page.goto(url, { waitUntil: 'load' }) + await page.getByRole('button', { name: '自动化任务', exact: true }).click() + const panel = page.getByTestId('task-manager-page') + await panel.getByRole('heading', { name: '自动化任务', exact: true }).waitFor({ timeout: 30_000 }) + await page.evaluate(() => document.fonts.ready) + return panel +} + +/** Read the facts above from the open view. */ +async function viewFacts(page: Page): Promise { + return page.evaluate(() => { + const panel = document.querySelector('[data-testid="task-manager-page"]') + const fields = Array.from(panel?.querySelectorAll('input, textarea, select') ?? []) + const steps = fields.map(element => Number.parseFloat(getComputedStyle(element).fontSize)) + const step = (selector: string): string => { + const element = document.querySelector(selector) + return element === null ? 'absent' : getComputedStyle(element).fontSize + } + return { + width: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + smallestField: steps.length === 0 ? -1 : Math.min(...steps), + name: step('[class*="_editName"]'), + search: step('[class*="_searchField"] input'), + instruction: step('[class*="_instruction"]'), + } + }) +} + +it('keeps the automation page on screen with its text fields at the touch step', async () => { + const scaffold = await launchWebScaffold() + const url = scaffold.authenticatedUrl + try { + await scaffold.ctx.schedule.create(PHONE_TASK_SESSION, { + prompt: 'Check the deployment log', title: PHONE_TASK_TITLE, at: '2099-08-25T12:08:00.000Z', + }) + const browser: Browser = await chromium.launch() + try { + // A coarse pointer is the touch device, so the phone context takes the + // 16px step. + const phone = await browser.newContext({ + viewport: { width: PHONE_PAGE_WIDTH, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, locale: 'zh-CN', + }) + const phonePage = await phone.newPage() + const phoneConsole = watchConsole(phonePage) + const phonePanel = await openTaskManager(phonePage, url) + await expect.poll(() => viewFacts(phonePage)).toEqual({ + width: PHONE_PAGE_WIDTH, + documentWidth: PHONE_PAGE_WIDTH, + smallestField: 16, + name: 'absent', + search: '16px', + instruction: 'absent', + }) + // The detail keeps the floor too, and the name control keeps the 20px step + // the floor must not flatten. + await phonePanel.getByRole('button', { name: PHONE_TASK_TITLE, exact: true }).click() + await phonePanel.locator('[class*="_detailScroll"]').waitFor({ timeout: 10_000 }) + await expect.poll(() => viewFacts(phonePage)).toEqual({ + width: PHONE_PAGE_WIDTH, + documentWidth: PHONE_PAGE_WIDTH, + smallestField: 16, + name: '20px', + search: '16px', + instruction: '16px', + }) + await phone.close() + + // A fine pointer keeps the compact steps: the floor is pointer-scoped. + const desktop = await browser.newContext({ viewport: { width: 1024, height: 800 }, locale: 'zh-CN' }) + const desktopPage = await desktop.newPage() + const desktopConsole = watchConsole(desktopPage) + const desktopPanel = await openTaskManager(desktopPage, url) + await expect.poll(() => viewFacts(desktopPage)).toEqual({ + width: 1024, + documentWidth: 1024, + smallestField: 14, + name: 'absent', + search: '14px', + instruction: 'absent', + }) + await desktopPanel.getByRole('button', { name: PHONE_TASK_TITLE, exact: true }).click() + await desktopPanel.locator('[class*="_detailScroll"]').waitFor({ timeout: 10_000 }) + await expect.poll(() => viewFacts(desktopPage)).toEqual({ + width: 1024, + documentWidth: 1024, + smallestField: 14, + name: '20px', + search: '14px', + instruction: '14px', + }) + await desktop.close() + + expect(phoneConsole.pageErrors).toEqual([]) + expect(phoneConsole.warnings).toEqual([]) + expect(desktopConsole.pageErrors).toEqual([]) + expect(desktopConsole.warnings).toEqual([]) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}, 180_000) diff --git a/apps/web/tests/session-inspector.e2e.ts b/apps/web/tests/session-inspector.e2e.ts index 2df97eeaa9..cba16b1571 100644 --- a/apps/web/tests/session-inspector.e2e.ts +++ b/apps/web/tests/session-inspector.e2e.ts @@ -46,12 +46,12 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click() const mode = page.getByRole('combobox', { name: 'Presentation', exact: true }) expect(await mode.inputValue()).toBe('session-log') - const raw = page.getByRole('table', { name: 'Raw Log', exact: true }) + const raw = page.getByRole('table', { name: 'Raw log', exact: true }) await raw.getByRole('button', { name: 'Filter types', exact: true }).click() await page.getByRole('combobox', { name: 'Type keyword', exact: true }).fill('user/message') await page.getByRole('option', { name: 'user/message', exact: true }).click() await raw.getByRole('button', { name: 'user/message', exact: true }).first().click() - const panel = page.locator('section[aria-label="Raw Log"]') + const panel = page.locator('section[aria-label="Raw log"]') const details = panel.getByRole('separator').locator('..').locator('ul').first() expect(await details.innerText()).toContain(prompts[0]) expect(await details.locator('details:not([open])').count()).toBe(0) @@ -64,14 +64,14 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C const replacements = [...new Set(times)].filter(value => /^\d{2}:\d{2}:\d{2}\.\d{3}$/.test(value)) .map(value => [value, '{{time}}'] as const) await compareOrRefreshGolden(join(directory, 'log.expected.md'), - await captureStableAria(page, 'section[aria-label="Raw Log"] table', scaffold.workspaceCwd, { replacements }), webSnapshotMode()) + await captureStableAria(page, 'section[aria-label="Raw log"] table', scaffold.workspaceCwd, { replacements }), webSnapshotMode()) await mode.selectOption('chat-node') - const groups = page.getByRole('table', { name: 'Chat Group', exact: true }) + const groups = page.getByRole('table', { name: 'Chat group', exact: true }) expect(await groups.getByRole('columnheader', { name: 'Time (UTC)' }).count()).toBe(0) await groups.getByRole('button', { name: 'assistant-step', exact: true }).first().click() await page.getByRole('navigation', { name: 'Raw data navigation', exact: true }).waitFor() - const groupPanel = page.locator('section[aria-label="Chat Group"]') - await groupPanel.locator('summary').filter({ hasText: 'Node Data' }).click() + const groupPanel = page.locator('section[aria-label="Chat group"]') + await groupPanel.locator('summary').filter({ hasText: 'Node data' }).click() const blocks = groupPanel.getByText('blocks:', { exact: true }).locator('..') await blocks.click() await blocks.locator('..').getByText('Other properties:', { exact: true }).waitFor() @@ -104,7 +104,7 @@ it.skipIf(webSnapshotMode() === 'record')('highlights nearby Chat content when t await page.getByText('DONE', { exact: true }).waitFor() await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click() - const table = page.getByRole('table', { name: 'Raw Log', exact: true }) + const table = page.getByRole('table', { name: 'Raw log', exact: true }) await table.getByRole('button', { name: 'Filter types', exact: true }).click() await page.getByRole('combobox', { name: 'Type keyword', exact: true }).fill('tool/call') await page.getByRole('button', { name: 'Apply filter', exact: true }).click() diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 9f45a7bc8b..4110b72e95 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -84,6 +84,17 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) expect(await trigger.getAttribute('aria-expanded')).toBe('false') + const panelBox = await dialog.boundingBox() + const optionsBox = await dialog.locator('[class*="_options"]').boundingBox() + const firstSectionBox = await dialog.getByRole('navigation').getByRole('button').first().boundingBox() + expect(optionsBox!.y - panelBox!.y).toBe(54) + expect(firstSectionBox!.y - panelBox!.y).toBe(64) + const lastSection = dialog.getByRole('navigation').getByRole('button').last() + await lastSection.focus() + await page.keyboard.press('Tab') + expect(await dialog.getByRole('button', { name: '打开配置文件' }).evaluate(el => el === document.activeElement)).toBe(true) + await page.keyboard.press('Tab') + expect(await dialog.getByRole('button', { name: '关闭', exact: true }).evaluate(el => el === document.activeElement)).toBe(true) // General is active by default; Permission, Language and Appearance are functional. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 }) @@ -210,7 +221,7 @@ describe('web e2e: settings modal and General preferences', () => { await page.keyboard.press('Escape') await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) expect(await trigger.getAttribute('aria-expanded')).toBe('false') - // Close path 2: the header close button (focus lands there on open). + // Close path 2: the header close button. await openSettings(page, 'zh') await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click() await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) @@ -227,7 +238,7 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = overflowPage.getByRole('dialog', { name: '设置', exact: true }) const navigation = dialog.getByRole('navigation') const list = navigation.locator('[class*="_navList"]') - const title = navigation.getByText('设置', { exact: true }) + const title = dialog.getByText('设置', { exact: true }) const options = dialog.locator('[class*="_options"]') const last = navigation.getByRole('button').last() await last.waitFor() @@ -261,6 +272,98 @@ describe('web e2e: settings modal and General preferences', () => { expect(console.warnings).toEqual([]) }) + it('fills a phone viewport and turns the rail into the horizontal section strip', async () => { + const phone = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE }) + onTestFinished(() => phone.close()) + onTestFailed(() => saveSettingsFailureShot(phone, 'web-e2e-settings-phone')) + const console = watchConsole(phone) + await phone.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(phone, 'zh') + const dialog = phone.getByRole('dialog', { name: '设置', exact: true }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '通用设置', exact: true }).waitFor({ timeout: 10_000 }) + + // The panel is the viewport: no centered card, no side margins, no rail. + await expect.poll(() => dialog.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + + // The rail reads left to right on one line instead of stacking. + const navigation = dialog.getByRole('navigation') + const list = navigation.locator('[class*="_navList"]') + const cells = navigation.getByRole('button') + const first = await cells.first().boundingBox() + const second = await cells.nth(1).boundingBox() + expect(first).not.toBeNull() + expect(second).not.toBeNull() + expect(second!.x).toBeGreaterThanOrEqual(first!.x + first!.width - 1) + expect(Math.abs(second!.y - first!.y)).toBeLessThan(1) + expect(await list.evaluate(el => getComputedStyle(el).overflowX)).toBe('auto') + + // The shipped sections overflow the strip, which scrolls sideways; the + // section column starts below it and keeps the panel's width. + const strip = await list.boundingBox() + const options = dialog.locator('[class*="_options"]') + const optionsBox = await options.boundingBox() + expect(strip).not.toBeNull() + expect(optionsBox).not.toBeNull() + expect(await list.evaluate(el => el.scrollWidth)).toBeGreaterThan(await list.evaluate(el => el.clientWidth)) + expect(optionsBox!.y).toBeGreaterThanOrEqual(strip!.y + strip!.height - 1) + expect(Math.round(optionsBox!.width)).toBe(390) + expect(await phone.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + + // Tapping a cell swaps the page, and a General row keeps its label and + // control on one line rather than stacking. + await cells.nth(1).click() + await expect.poll(() => cells.nth(1).getAttribute('aria-current')).toBe('true') + await expect.poll(() => dialog.getByText('权限', { exact: true }).count()).toBe(0) + await cells.first().click() + await expect.poll(() => dialog.getByText('权限', { exact: true }).count(), { timeout: 5_000 }).toBe(1) + const permissionRow = dialog.getByText('权限', { exact: true }).locator('..') + const permissionBox = await permissionRow.boundingBox() + const controlBox = await dialog.getByRole('button', { name: '工作区内修改' }).boundingBox() + expect(controlBox).not.toBeNull() + expect(permissionBox).not.toBeNull() + expect(controlBox!.y).toBeLessThan(permissionBox!.y + permissionBox!.height) + + // The Appearance cubes share one row instead of wrapping to two. + const cubes = dialog.getByRole('button', { name: /浅色|深色|跟随系统/ }) + const cubeBoxes = await cubes.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().top)) + expect(new Set(cubeBoxes.map(top => Math.round(top))).size).toBe(1) + + await cells.first().focus() + for (let index = 1; index < await cells.count(); index += 1) { + await phone.keyboard.press('Tab') + } + const last = cells.last() + expect(await last.evaluate(el => el === document.activeElement)).toBe(true) + const lastBox = await last.boundingBox() + // Scroll offsets can round while glyph widths remain fractional. + expect(Math.round(lastBox!.x)).toBeGreaterThanOrEqual(0) + expect(Math.round(lastBox!.x + lastBox!.width)).toBeLessThanOrEqual(390) + await last.click() + await expect.poll(() => last.getAttribute('aria-current')).toBe('true') + await phone.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await openSettings(phone, 'zh') + const active = dialog.locator('[aria-current="true"]') + expect(await active.textContent()).toBe('通用设置') + expect(await active.evaluate(el => el === document.activeElement)).toBe(true) + + const close = dialog.getByRole('button', { name: '关闭', exact: true }) + const closeBox = await close.boundingBox() + expect(closeBox!.width).toBe(28) + expect(closeBox!.x).toBeGreaterThanOrEqual(0) + expect(closeBox!.y).toBeGreaterThanOrEqual(0) + expect(closeBox!.x + closeBox!.width).toBeLessThanOrEqual(390) + expect(closeBox!.y + closeBox!.height).toBeLessThanOrEqual(844) + await close.click() + await dialog.waitFor({ state: 'hidden' }) + const trigger = phone.getByRole('button', { name: '账号菜单', exact: true }) + await expect.poll(() => trigger.evaluate(el => el === document.activeElement)).toBe(true) + + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + }) + it('stores Permission as the default for future sessions without changing an existing session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) const createSession = async (id: string) => { diff --git a/apps/web/tests/shared-inspector.e2e.ts b/apps/web/tests/shared-inspector.e2e.ts index 74a65d498b..d72ff60197 100644 --- a/apps/web/tests/shared-inspector.e2e.ts +++ b/apps/web/tests/shared-inspector.e2e.ts @@ -42,7 +42,7 @@ it('toggles bottom DevTools from the page and iframe without a selected Session' const close = panel.getByRole('button', { name: 'Collapse', exact: true }) expect(await close.textContent()).toBe('') expect(await close.locator('svg').count()).toBe(1) - const divider = panel.getByRole('separator', { name: 'Resize NodeJS Inspector panel' }) + const divider = panel.getByRole('separator', { name: 'Resize NodeJS inspector panel' }) const drag = async (deltaY: number): Promise => { const box = (await divider.boundingBox())! const x = box.x + box.width / 2 @@ -93,10 +93,10 @@ it.each([ openSidebar: '打开右侧边栏', sidebarDescription: '在侧边栏分析当前会话原始日志和聊天分组数据', }, { - locale: 'en-US', file: 'en', plugins: 'Plugins', open: 'View Developer Tools', bundle: 'Developer Tools', + locale: 'en-US', file: 'en', plugins: 'Plugins', open: 'View Developer tools', bundle: 'Developer tools', description: 'Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.', - inspector: 'NodeJS Inspector', inspectorDescription: 'Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.', - session: 'Session Log', sessionDescription: "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.", + inspector: 'NodeJS inspector', inspectorDescription: 'Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.', + session: 'Session log', sessionDescription: "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.", openSidebar: 'Open right sidebar', sidebarDescription: 'Analyze raw logs and grouped chat messages for the current session in the sidebar.', }, ])('shows the Inspector bundle and component copy in $locale', async (copy) => { @@ -150,7 +150,7 @@ it.each([ ` description: ${await sessionGuide.getByText(copy.sidebarDescription, { exact: true }).innerText()}`, `- NodeJS bottom: ${await bottomTitle.innerText()}`) await sessionGuide.click() - await page.getByRole('table', { name: copy.locale === 'zh-CN' ? '原始数据' : 'Raw Log', exact: true }).waitFor() + await page.getByRole('table', { name: copy.locale === 'zh-CN' ? '原始数据' : 'Raw log', exact: true }).waitFor() await compareOrRefreshGolden(fileURLToPath(new URL(`./expected/shared-inspector/metadata-${copy.file}.expected.md`, import.meta.url)), snapshot.join('\n'), webSnapshotMode()) }) diff --git a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md index 19f90be9a3..57fbab1f84 100644 --- a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md @@ -1,4 +1,4 @@ -- dialog "Agent Team": +- dialog "Agent team": - heading "Members" [level=3] - button "lead Current chat Inactive" [disabled] - heading "Shared tasks 2" [level=3] diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 9e6f63532e..d5593a069b 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,4 +1,4 @@ -- dialog "Agent Team": +- dialog "Agent team": - heading "Members" [level=3] - button "lead Current chat Inactive" [disabled] - heading "Shared tasks 1" [level=3] diff --git a/apps/web/tests/worktree-opt-in.e2e.ts b/apps/web/tests/worktree-opt-in.e2e.ts index 40d4e58857..37b94a16ec 100644 --- a/apps/web/tests/worktree-opt-in.e2e.ts +++ b/apps/web/tests/worktree-opt-in.e2e.ts @@ -70,9 +70,9 @@ it('persists the Git Worktrees switch and removes its capability while retaining } let panel = await openPlugins() let card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) - await card.getByRole('button', { name: 'View Git Worktrees', exact: true }).waitFor() + await card.getByRole('button', { name: 'View Git worktrees', exact: true }).waitFor() expect(await card.getByText('Experimental', { exact: true }).count()).toBe(1) - let toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true }) + let toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) expect(await toggle.getAttribute('aria-checked')).toBe('false') expect(await selectedBundles()).not.toContain(BUNDLE) expect(rows()).toEqual([]) @@ -92,7 +92,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining await page.reload({ waitUntil: 'load' }) panel = await openPlugins() card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) - toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true }) + toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') expect(tools()).toContain('create_worktree') await toggle.click() @@ -108,7 +108,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining await page.reload({ waitUntil: 'load' }) panel = await openPlugins() - await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git Worktrees', exact: true }) + await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) .getAttribute('aria-checked')).toBe('false') expect(tools()).not.toContain('create_worktree') expect(await selectedBundles()).not.toContain(BUNDLE) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 6a03b44055..ad7b72f14f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,7 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ + "tests/rich-text-quotes.e2e.ts", "tests/hover-input.e2e.ts", "tests/collapse-timing.e2e.ts", "tests/notice-wake-tail.e2e.ts", @@ -89,6 +90,7 @@ "tests/cua-optin.e2e.ts", "tests/cua-permissions.e2e.ts", "tests/schedule-header.e2e.ts", + "tests/schedule-phone.e2e.ts", "tests/worktree-opt-in.e2e.ts", "tests/session-inspector.e2e.ts", "tests/shared-inspector.e2e.ts", diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index 00d5f17d33..5e0b163aa1 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -30,7 +30,6 @@ import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline import TokenMeter from '@deepseek-ai/dsh-token-meter' // These Host-only adapters have no public Node export and are compiled into the benchmark worker. import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts' -import { modelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts' @@ -131,7 +130,6 @@ function memoryDelta( async function installProjectionSet(ctx: Context): Promise { // Mirrors projection owners mounted by the base and web-app bundles without timing profile boot. await ctx.sessionProjections.register(agentPresetProjectionDefinition) - await ctx.sessionProjections.register(modelSelectionProjection) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 0e45a2de2e..d7cc383707 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -24,11 +24,11 @@ en: e89802f066e4955d zh: ea2fb4bef94e086d /deepseek-harness-architecture/turn-flow: - en: 7a2a9d68e4fa1fe4 - zh: 67f40bac3d1959cc + en: d2e54d2e197cee82 + zh: e085148bb4ae8edd /deepseek-harness-architecture/session-log: - en: f1729d805c20df36 - zh: 6b71b6039663e2e7 + en: 8a7f19b95d426c99 + zh: d5bb0538404e20ea /deepseek-harness-architecture/capability-seams: en: a5bc20c3f2a1b370 zh: 91773a281410b2f4 diff --git a/docs/architecture.md b/docs/architecture.md index 8ca39eb0e1..1b58e416e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ A **step** is one model request plus the tools it calls. A **turn** is zero or m ```text turn/start claim next-step input plus one queued message - assemble prompt sections + tool schemas + snapshot Session model selection; assemble prompt sections + tool schemas -> agent/pre-step; project runtime context at fallback reject | enter(messages, startsRequestSeries?) reject, or a first enter rewritten empty -> close the turn with no step @@ -109,7 +109,7 @@ Factory setup follows SessionStore entry and `session/created`, preceding `agent One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents. -`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes append non-empty updates after cached history, including supported tool updates; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). +The loop snapshots Session model selection before assembly, sharing it through `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain that selection, and omitted reasoning effort uses adapter defaults. `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details. The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes. @@ -125,7 +125,7 @@ Session consumers know only the current logical format. Header-only `stat` and ` **Model-visible means logged.** Every model request must be reconstructable from the log. New model-visible inputs require session events. Plugins changing existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider declarations. -**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Domain services await registration during activation, retain private handles, and expose business methods for other plugins. Releasing registration removes global participation; retained handles continue folding Session events. `AgentRegistry` owns turn and step state and exposes `turnBoundaryOf(session)` ([registration lifecycle](../packages/session/session-projection/README.md)). +**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Domain services await registration during activation, retain private handles, and expose business methods for other plugins. Releasing registration removes global participation; retained handles continue folding Session events. `AgentRegistry` owns turn and step state and exposes `turnBoundaryOf(session)` ([registration lifecycle](../packages/session/session-projection/README.md)). `AgentLoop` owns `modelSelection` and constructs each Agent’s `model` instance alongside its inbox. [Working directories](subsystems/working-directory.md) supply user context and execution paths without changing original project identity, sandbox write roots, or existing process directories. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 74469a14fb..00d1bd8f7e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -87,7 +87,7 @@ AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失 ```text turn/start claim next-step input plus one queued message - assemble prompt sections + tool schemas + snapshot Session model selection; assemble prompt sections + tool schemas -> agent/pre-step; project runtime context at fallback reject | enter(messages, startsRequestSeries?) reject, or a first enter rewritten empty -> close the turn with no step @@ -113,7 +113,7 @@ turn/end 输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。 -`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新,包括同时发生的受支持工具更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 +循环在组装前快照 Session 模型选择,并通过 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection` 传递该选择;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。 循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。 @@ -129,7 +129,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` **模型可见即已记录。** 每个模型请求都必须能从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方声明。 -**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。领域服务在激活时等待注册完成,在内部保留句柄,并向其他插件提供业务方法。释放注册会移除全局参与关系;已有句柄仍可继续折叠 Session 事件。`AgentRegistry` 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`([注册生命周期](../packages/session/session-projection/README.zh.md))。 +**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。领域服务在激活时等待注册完成,在内部保留句柄,并向其他插件提供业务方法。释放注册会移除全局参与关系;已有句柄仍可继续折叠 Session 事件。`AgentRegistry` 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`([注册生命周期](../packages/session/session-projection/README.zh.md))。 `AgentLoop` 持有 `modelSelection`,并与 inbox 一起构建每个 Agent 的 `model` 实例。 [工作目录](subsystems/working-directory.zh.md) 提供用户上下文和执行路径,不改变原始项目标识、沙箱写入根目录或已有进程目录。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 54378b11df..d71171b7bb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -16,7 +16,7 @@ Each package entry labels its data with three identifiers: `inject` lists the se - `inject`: `agents` · `llm` · `sessionPersistence` · `sessions` - `refs`: `Stream` (`@agentclientprotocol/sdk`) -- `source`: [`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts) +- `source`: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) ```ts config-catalog /** Plugin config: the provider/model selection used for each ACP-created agent. */ @@ -96,7 +96,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:248`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:251`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -106,7 +106,7 @@ export interface Config { * omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. */ maxParallelToolCalls: Volatile - /** Agents created or resumed at plugin startup. */ + /** Agents created or resumed at plugin startup; a resumed agent records its configured route as the Session's model selection. */ agents: (AgentOptions & { /** Stable config label used in logs and as the fresh combined-id prefix. */ id: string @@ -2604,7 +2604,7 @@ export interface Config { maxOutputBytes?: number /** V8 old-generation heap limit in MiB; native allocations are excluded. */ maxOldGenerationSizeMb?: number - /** Maximum control frame, outstanding argument and queued control-output bytes. */ + /** Maximum control message, outstanding binding-argument and queued control-output bytes. */ maxMessageBytes?: number /** Maximum simultaneous host binding calls accepted from a program. */ maxPendingCalls?: number @@ -4785,6 +4785,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) | | `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) | | `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) | +| `@deepseek-ai/dsh-message-quotes` | — | [`packages/session/message-quotes/src/index.ts`](../packages/session/message-quotes/src/index.ts) | | `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) | | `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) | | `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b306246e66..c5c978b2dc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -18,7 +18,7 @@ - `inject`: `agents` · `llm` · `sessionPersistence` · `sessions` - `refs`: `Stream` (`@agentclientprotocol/sdk`) -- `source`: [`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts) +- `source`: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) ```ts config-catalog /** Plugin config: the provider/model selection used for each ACP-created agent. */ @@ -98,7 +98,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:248`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:251`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -108,7 +108,7 @@ export interface Config { * omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. */ maxParallelToolCalls: Volatile - /** Agents created or resumed at plugin startup. */ + /** Agents created or resumed at plugin startup; a resumed agent records its configured route as the Session's model selection. */ agents: (AgentOptions & { /** Stable config label used in logs and as the fresh combined-id prefix. */ id: string @@ -2606,7 +2606,7 @@ export interface Config { maxOutputBytes?: number /** V8 old-generation heap limit in MiB; native allocations are excluded. */ maxOldGenerationSizeMb?: number - /** Maximum control frame, outstanding argument and queued control-output bytes. */ + /** Maximum control message, outstanding binding-argument and queued control-output bytes. */ maxMessageBytes?: number /** Maximum simultaneous host binding calls accepted from a program. */ maxPendingCalls?: number @@ -4787,6 +4787,7 @@ export interface Config { | `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) | | `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) | | `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) | +| `@deepseek-ai/dsh-message-quotes` | — | [`packages/session/message-quotes/src/index.ts`](../packages/session/message-quotes/src/index.ts) | | `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) | | `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) | | `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) | diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 2fb8b9bd67..de5eb1d67b 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -21,8 +21,8 @@ en: 0894bd9f35430753 zh: 2d28090e71134edd /cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata: - en: ed501a72ab8ba6e7 - zh: 8ad775b735887a10 + en: cb799f5d561dd917 + zh: 7739af36ef8ffb3f /cookbook-adding-a-workspace-package/6-verify: en: 80c7fb90b1175aae zh: 2ba37962dbc57299 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 1f86f1b9bd..d5dc6454a8 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -136,7 +136,7 @@ Merge these entries into `package.json`, retaining existing runtime exports and } ``` -Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic. +Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. English `meta.title` and `meta.description` use sentence case: capitalize only the first word, proper nouns, and product names, as in `Git worktrees` ([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic. Fields fall back independently before view-specific name formatting, using the existing locale language chain first: diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 3e13580fe9..4ef55c443a 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -136,7 +136,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex } ``` -语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。 +语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。英文 `meta.title` 和 `meta.description` 使用 sentence case:只大写首个单词、专有名词和产品名,例如 `Git worktrees`([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location))。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。 各字段先独立回退,再按页面规则格式化技术名称;回退优先经过现有 locale 语言链: diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index e841296f69..ff778ef2c5 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -21,5 +21,5 @@ en: f0a28d07c0bb4d1b zh: 840962f512cb4031 /cookbook-extension-plugin-shapes/the-feature-mechanism-map: - en: fa917d46c071fad2 - zh: 4062805237e2c847 + en: af09d0fbb5f39df9 + zh: 189166baa84868bc diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 006b6b7092..7e960a85f5 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -103,7 +103,7 @@ Every product feature maps to a listener on a documented extension point — the | Product feature | Plugin mechanism | |---|---| -| Hook system (user + project level) | listeners on `agent/created`, `agent/pre-step`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfalls return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude-code` / `dsh-hooks-codex` bridges map hook config files onto these extension points | +| Hook system (user + project level) | listeners on `agent/created`, `agent/pre-step`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfalls return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude-code` / `dsh-hooks-codex` bridges map hook config files onto these extension points | | `/goal` | `ctx.goals` owns durable state, `dsh-goal-round-driver` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | | `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflowEngine` + the PTC workflow engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and the structured-output execution's monotonic `concludeTurn()` marker | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 826510f6a5..04eaa40533 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -107,7 +107,7 @@ export function apply(ctx: Context) { | 产品功能 | 插件机制 | |---|---| -| 钩子系统(用户级 + 项目级) | `agent/created`、`agent/pre-step`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall 返回类型化决策,`agent/turn-stopping` 则可通过 steering(中途引导)触发下一步;`dsh-hooks-claude-code` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些扩展点上 | +| 钩子系统(用户级 + 项目级) | `agent/created`、`agent/pre-step`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall 返回类型化决策,`agent/turn-stopping` 则可通过 steering(中途引导)触发下一步;`dsh-hooks-claude-code` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些扩展点上 | | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-round-driver` 通过公共 `Agent` 调度同会话 Round,独立的命令/工具生产方分别提供人类/模型控制 | | `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflowEngine` + PTC 工作流引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的提示词/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和结构化输出执行的单调 `concludeTurn()` 标记来强制输出 | diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml index a85c7593aa..258f815ca5 100644 --- a/docs/cordis-tutorial/04-events.i18n.yaml +++ b/docs/cordis-tutorial/04-events.i18n.yaml @@ -12,5 +12,5 @@ en: 24799f579b04b2d4 zh: 97d4d82dc3a02c75 /4-events/waterfall-transform-or-short-circuit: - en: 8309aea415b395c3 - zh: 480ee4a163543dd0 + en: a0027cc61e674138 + zh: c96dd1e7d559b748 diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index db911b9a06..5f5118ebc3 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -137,7 +137,7 @@ Walk through the second line: listener 1 runs first, calls `next()`, which invok The discipline that follows: **a waterfall listener that only observes or annotates must call `next()`**; returning without it is a deliberate short-circuit. Forgetting `next()` in a logging listener silently swallows the default behavior for everyone downstream. It is a standing rule of this repository ([waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)). -The harness uses waterfalls for decisions that cooperating plugins may wrap or answer: [`agent/request`](../subsystems/core.md#agentrequest--waterfall) lets a plugin replace the model-call config, and [`approval/request`](../subsystems/approval.md#approvalrequest--waterfall) lets a policy answer instead of the user. +The harness uses waterfalls for decisions that cooperating plugins may wrap or answer: [`agent/pre-step`](../subsystems/core.md#agentpre-step--waterfall) lets a plugin reject a proposed step or replace its input, and [`approval/request`](../subsystems/approval.md#approvalrequest--waterfall) lets a policy answer instead of the user. Next: [Configuration](05-config.md) — plugin options from `cordis.yml`. diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md index 0125f81c55..fa71ac249c 100644 --- a/docs/cordis-tutorial/04-events.zh.md +++ b/docs/cordis-tutorial/04-events.zh.md @@ -137,7 +137,7 @@ HELLO 由此得到一项纪律:**只负责观察或标注的 waterfall 监听器必须调用 `next()`**;不调用就直接返回代表有意短路。如果日志监听器忘记调用 `next()`,会悄无声息地吞掉所有下游的默认行为。这是本仓库的常设规则([waterfall 语义](../cordis-primer.zh.md#cordis-waterfall-semantics))。 -harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`agent/request`](../subsystems/core.zh.md#agentrequest--waterfall) 允许插件替换模型调用配置,[`approval/request`](../subsystems/approval.zh.md#approvalrequest--waterfall) 允许策略代替用户作答。 +harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`agent/pre-step`](../subsystems/core.zh.md#agentpre-step--waterfall) 允许插件拒绝拟议步骤或替换其输入,[`approval/request`](../subsystems/approval.zh.md#approvalrequest--waterfall) 允许策略代替用户作答。 下一章:[配置](05-config.zh.md):来自 `cordis.yml` 的插件选项。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 633bd2595e..902ee9ff7c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,25 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:194`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:197`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`compaction-basic`](../packages/compaction/compaction-basic), [`webhook`](../packages/experimental/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -74,7 +73,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`working-directory`](../packages/session/working-directory) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | @@ -99,6 +98,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | +| `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | | `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 337cea1172..3745823f05 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,25 +10,24 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:194`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:197`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`compaction-basic`](../packages/compaction/compaction-basic), [`webhook`](../packages/experimental/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -76,7 +75,7 @@ | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`working-directory`](../packages/session/working-directory) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | @@ -101,6 +100,7 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | +| `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | | `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | diff --git a/docs/module-graph.md b/docs/module-graph.md index ccafa41607..02f1745630 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -374,6 +374,7 @@ flowchart TD pkg_sdk_protocol["sdk-protocol"] end subgraph group_session["packages/session"] + pkg_message_quotes["message-quotes"] pkg_session_checkpoint_policy["session-checkpoint-policy"] pkg_session_format["session-format"] pkg_session_format_catalog["session-format-catalog"] @@ -1564,6 +1565,7 @@ flowchart TD | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — | | [`host-open-in-app`](../packages/host/open-in-app) | `host` | — | | [`host-webserver`](../packages/host/webserver) | `host` | — | +| [`message-quotes`](../packages/session/message-quotes) | `session` | — | | [`session-format`](../packages/session/session-format) | `session` | — | | [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — | | [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 3f6d7c9348..037a7096ce 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -376,6 +376,7 @@ flowchart TD pkg_sdk_protocol["sdk-protocol"] end subgraph group_session["packages/session"] + pkg_message_quotes["message-quotes"] pkg_session_checkpoint_policy["session-checkpoint-policy"] pkg_session_format["session-format"] pkg_session_format_catalog["session-format-catalog"] @@ -1566,6 +1567,7 @@ flowchart TD | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — | | [`host-open-in-app`](../packages/host/open-in-app) | `host` | — | | [`host-webserver`](../packages/host/webserver) | `host` | — | +| [`message-quotes`](../packages/session/message-quotes) | `session` | — | | [`session-format`](../packages/session/session-format) | `session` | — | | [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — | | [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 5d85d44634..aad6840526 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -21,8 +21,8 @@ en: 9188862058c6986b zh: 9188862058c6986b /session-persistence-event-catalog/events/agent/agent-inbox-spliced-log-only: - en: 8309b4babad90744 - zh: b344ac640d79947e + en: 481727894895b43b + zh: 098bc9788bf88968 /session-persistence-event-catalog/events/agent-preset: en: 49891ca9be5ac123 zh: 49891ca9be5ac123 @@ -141,8 +141,8 @@ en: 9edf4ef31e60e880 zh: 9edf4ef31e60e880 /session-persistence-event-catalog/events/model/model-selection-log-only: - en: 06589ec99fb2bb40 - zh: 3b78f0418d44609b + en: c2a60ec7d297ed79 + zh: a2f6b2d3c733ce96 /session-persistence-event-catalog/events/permission: en: 19917bc5438e60af zh: 19917bc5438e60af @@ -1074,8 +1074,8 @@ en: a3087ab7bebe9371 zh: abf6afc2f9f5b692 /session-persistence-event-catalog/resolved-persistence-types/inboxtarget: - en: 938113c1b01efad1 - zh: d6829dbe979e6819 + en: b3b7b8a3414bcb6b + zh: 06be5b8e56bacedf /session-persistence-event-catalog/resolved-persistence-types/jobregistereddata: en: f604eebb973fe2cf zh: 0c0aba1e17ecf66e @@ -1143,11 +1143,11 @@ en: 8bd12543fcdcda5f zh: d4c6b17083948856 /session-persistence-event-catalog/resolved-persistence-types/modelmessagesource: - en: 3b677f021d7dd4d3 - zh: 2a875742b22879a2 + en: 5f9154ca34235ab9 + zh: a833105910ebfa82 /session-persistence-event-catalog/resolved-persistence-types/modelselection: - en: bf5990688bae50ac - zh: 9212bca7aa7d31e0 + en: 6e301c0ea35d9e0e + zh: 6fbf1d318db9a4e0 /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: en: a3300babe57b6b67 zh: 4aaf06a149b399c3 @@ -1347,8 +1347,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 41c03b03d1bd5a44 - zh: 1b0e430af6c140b3 + en: 967168de7dce17f6 + zh: c43778dcbfea301e /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1524,8 +1524,8 @@ en: 8a9199b1784c70f5 zh: 76c98bc74686fda0 /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: 452ff7ab39ffb3a9 - zh: 33af56489f0a45bc + en: 346d884a7a74e48d + zh: 646ac1021f9ca920 /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: en: ec10cd8e9c202a81 zh: e08f859bcb13f0d4 @@ -1833,8 +1833,8 @@ en: 809a23c25ded8497 zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 53b7abac679ee252 - zh: 63eb6a015986ef6d + en: 43f0c6042d410afd + zh: a0b68de4e5d220bd /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4e67d6aca5..7e8cbf874e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -185,7 +185,7 @@ Sources: [`packages/core/session/src/types.ts:432`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -694,13 +694,13 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src ```ts persistence-catalog /** - * Complete validated model selection requested for subsequent prompt - * assembly. Log-only: it never enters derived model history. + * Complete model selection that applies from the next model step until a + * later selection. Log-only: it never enters derived model history. */ 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:37`](../packages/api/session-controller/src/types.ts) +Source: [`packages/core/agent/src/types.ts:121`](../packages/core/agent/src/types.ts) ### `permission/*` @@ -3788,7 +3788,7 @@ Array of [`ImageOffloadTarget`](#persistence-type-sha256-1cde1bedde79c5e8a56aee9 SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c` -Sources: [`packages/core/agent/src/types.ts:39`](../packages/core/agent/src/types.ts) +Sources: [`packages/core/agent/src/types.ts:40`](../packages/core/agent/src/types.ts) One of: @@ -4331,13 +4331,13 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. - + ### `ModelSelection` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:98`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/core/agent/src/types.ts:79`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5583,7 +5583,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:379`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -6377,7 +6377,7 @@ Sources: [`packages/core/session/src/types.ts:428`](../packages/core/session/src SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` -Sources: [`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) +Sources: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7554,7 +7554,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 35e8733446..4233189dfd 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -187,7 +187,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -696,13 +696,13 @@ export type SessionEvent = { ```ts persistence-catalog /** - * Complete validated model selection requested for subsequent prompt - * assembly. Log-only: it never enters derived model history. + * Complete model selection that applies from the next model step until a + * later selection. Log-only: it never enters derived model history. */ 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:37`](../packages/api/session-controller/src/types.ts) +来源:[`packages/core/agent/src/types.ts:121`](../packages/core/agent/src/types.ts) ### `permission/*` @@ -3790,7 +3790,7 @@ SHA-256: `9e2c06d8653585b33d72d5c1a80da79c8a24a88c284409bca15cc0f0ead5ebfb` SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c` -来源:[`packages/core/agent/src/types.ts:39`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:40`](../packages/core/agent/src/types.ts) 以下类型之一: @@ -4333,13 +4333,13 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` - + ### `ModelSelection` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:98`](../packages/api/session-controller/src/types.ts) +来源:[`packages/core/agent/src/types.ts:79`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:379`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -6379,7 +6379,7 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` -来源:[`packages/core/agent/src/types.ts:84`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 8b2a6ff8a8..391f4a2637 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -253,7 +253,7 @@ {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:194"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:406","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:379","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, {"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:62","packages/web/web-search-deepseek/src/provider.ts:66"]}, @@ -335,7 +335,7 @@ {"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]}, {"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]}, {"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]}, - {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:415"]}, + {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:388"]}, {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]}, {"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]}, {"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]}, @@ -430,7 +430,7 @@ {"digest":"95c0e88fcf0bafb2087f912ad9e2369de6ae6530cc0c4c2772c96205b57f7f60","names":[],"sources":[]}, {"digest":"9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114","names":[],"sources":[]}, {"digest":"96bfb916a727cd4445a594df264c523fc039749a31bfed924b8cc7bb308a5bb0","names":[],"sources":[]}, - {"digest":"96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c","names":["packages/core/agent/src/types.ts#InboxTarget"],"sources":["packages/core/agent/src/types.ts:39"]}, + {"digest":"96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c","names":["packages/core/agent/src/types.ts#InboxTarget"],"sources":["packages/core/agent/src/types.ts:40"]}, {"digest":"97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c","names":[],"sources":[]}, {"digest":"9890113e97dd3541787bfe6704f2b223d3a890b5325ca0acae2b0398909b413e","names":[],"sources":["packages/sandbox/sandbox-policy/src/session-mode.ts:33"]}, {"digest":"99135d8a3937977955eaac5f8f04f930dcd1562f8450a54a51cde9b921dd59a6","names":[],"sources":[]}, @@ -640,7 +640,7 @@ {"digest":"ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21","names":[],"sources":[]}, {"digest":"ef4f00923b9edbae15c7ef05320280bf61147ca17146cf59690359bfd5e5da45","names":[],"sources":[]}, {"digest":"f05fb85e715af4ab4de0b6ba111166dd0435bd8bb74a0fa0f32b9d174113fc7c","names":[],"sources":[]}, - {"digest":"f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b","names":["packages/api/session-controller/src/types.ts#ModelSelection"],"sources":["packages/api/session-controller/src/types.ts:98"]}, + {"digest":"f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b","names":["packages/core/agent/src/types.ts#ModelSelection"],"sources":["packages/core/agent/src/types.ts:79"]}, {"digest":"f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402","names":["packages/core/session/src/types.ts#TurnEndReason"],"sources":["packages/core/session/src/types.ts:233"]}, {"digest":"f1996af2602e6f62b5ab179ca5c4af00d8753938b74cc6ef102de09e99e74a7e","names":[],"sources":[]}, {"digest":"f19bee84bf3d20904553cdcd57465232bc1c2a194f54c4d195292ba0487223a6","names":["packages/attachment/attachment/src/types.ts#FileAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:39"]}, @@ -666,7 +666,7 @@ {"digest":"f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d","names":[],"sources":["packages/core/session/src/types.ts:289","packages/deliverables/workspace-changes/src/types.ts:106"]}, {"digest":"f97f47b0eb87905ed77cc27c5e4ed75ef805cb372c98905253709de1aab9b63c","names":[],"sources":[]}, {"digest":"f9cfc89e8fe085b4fcc9cd32b74b6f103608c54d7d8cd97b47c01de34f252ba3","names":[],"sources":[]}, - {"digest":"fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42","names":[],"sources":["packages/core/agent/src/types.ts:84"]}, + {"digest":"fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, {"digest":"fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be","names":["packages/llm/llm/src/message.ts#AssistantMessage"],"sources":["packages/llm/llm/src/message.ts:167"]}, {"digest":"fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd","names":["packages/sandbox/sandbox/src/index.ts#SandboxMode"],"sources":["packages/sandbox/sandbox/src/index.ts:30"]}, {"digest":"fab35c4520883c0d69e42d079c9f543fc5ac7a2d15b537b5572fcd99059c7018","names":[],"sources":[]}, diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index deb8044786..02e7a5d4f6 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -12,8 +12,8 @@ en: e516e14228c6bd35 zh: dba25d7da1d5a44e /core/the-agent-handle: - en: c21787347d8e2b14 - zh: 25bc9cb60ee1520a + en: 6ee15fec81bc0686 + zh: b6f3fcd48823ea75 /core/initiating-agent: en: 6c8c47ac78de2dde zh: c9d527a25a85d1d9 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index e9ff98e3da..86df19aa94 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -61,10 +61,12 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types interface Agent { /** Session-backed Agent identity. */ readonly id: SessionId - /** The provider route and model this agent's requests use. */ + /** Route fallback and output controls; live accessors apply until the Session records a selection or request. */ readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** Agent-owned access to durable model selection for future steps. */ + readonly model: Model /** Agent-owned access to durable pending work. */ readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ @@ -193,20 +195,86 @@ type AssistantStreamFrame = `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `followup()` returns no handle: its `MessageId` identifies durable inbox insertion, claim, and discard facts, not a later assistant output or turn ending. `whenIdle()` observes the whole agent, so callers may call a receipt-to-idle interval a run only when they explicitly own that interval ([decision](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). ```ts type-equiv -/** Merge-extensible agent creation options. Persona belongs to system-prompt sections. */ +/** + * Merge-extensible agent options. Route fields may be live accessors until the + * Session records a selection or request; step preparation captures their value. + * Persona belongs to system-prompt sections. + */ interface AgentOptions { /** Provider route (must have a registered adapter at call time). */ provider?: string /** Model id interpreted by the selected provider adapter. */ model?: string /** Adapter-owned reasoning effort for the selected provider/model route. */ - reasoningEffort?: ReasoningEffortId + reasoningEffort?: ReasoningEffortId | undefined /** Maximum output tokens for each conversation-model request. */ maxTokens?: number } ``` -Dispatch requires `provider` and `model` after `agent/request`. An explicit `reasoningEffort` seeds the first request on that route; exact-model resolution validates it, while omission allows the adapter default to materialize. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona-prefix` prompt section may shadow the global default persona. +Step preparation requires `provider` and `model` before prompt assembly. The deprecated `agent/request` hook may change request controls but must preserve that route. An explicit `reasoningEffort` seeds the first request on that route; exact-model resolution validates it, while omission allows the adapter default to materialize. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona-prefix` prompt section may shadow the global default persona. + +`AgentLoop` registers `modelSelection` on `ctx.sessionProjections` and exposes selection operations through each `agent.model` instance. `model/selection` records a complete choice without entering model history. A matching request header clears `pending`; the latest request remains `lastUsed`. Adapter-defaulted effort is omitted so later requests resolve the default again. `next` is null only before either a choice or a request is recorded; in that case `AgentOptions` supplies the route. The [Agent model API](../../packages/core/agent/README.md) describes selection precedence and resume policy. + +```ts type-equiv +/** Agent-owned access to the model choice for future steps. */ +interface Model { + /** + * Resolve the choice for the next step to begin preparation. Recorded choices + * take precedence over the last request and then the Agent's live options. + * This read neither starts a step nor changes an already-prepared step. + * @returns a detached choice, or undefined when no source names both provider and model. + */ + current(): Readonly | undefined + + /** + * Replace the next step's complete selection. Omitted effort follows adapter + * defaults without inheriting a previous effort or resolving defaults here. + * An equal recorded choice is a no-op; an options-only fallback is + * always recorded. Callers own model availability and capability validation. + * The append uses the Session's existing persistence and failure guarantees. + * @param selection - complete provider, model, and optional explicit effort. + */ + select(selection: Readonly): void + +} +``` + +```ts type-equiv +/** Complete provider, model, and optional reasoning effort chosen for one Session's model steps. */ +interface ModelSelection { + /** Registered provider route. */ + provider: string + /** Provider-owned model id. */ + model: string + /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ + reasoningEffort?: ReasoningEffortId +} +``` + +```ts type-equiv +/** + * Durable model-selection state for one Session. An effort the adapter + * defaulted on a recorded request is omitted, so later requests resolve the + * default again instead of pinning it as a choice. + */ +interface ModelSelectionProjectionState { + /** Selection used by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null + /** Later recorded selection not yet used by a matching model request. */ + readonly pending: ModelSelection | null +} +``` + +```ts type-equiv +/** Client view of the durable model-selection fold. */ +interface ModelSelectionProjection { + /** Selection used by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null + /** Selection the next model step uses; null while the Agent's initial options supply the route. */ + readonly next: ModelSelection | null +} +``` The inbox is the delivery vocabulary — two ordered pending-message lists the agent owns as a durable projection: @@ -976,46 +1044,17 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. * @param payload.signal - the current turn's cancellation signal. + * @param payload.modelSelection - captured step selection, retained across retries; omitted effort uses adapter defaults. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal; modelSelection: Readonly }, next: () => Promise): Promise ``` Types: [Scoped](scope.md) · [UserMessage](session.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - - -#### `agent/request` — waterfall - -Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. On step admission, this runs after assembly and `step/start`, before the system prompt and accepted user batch are committed. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages. - -```ts cordis-catalog -/** - * Replace the frozen call configuration. `await next()` yields the config - * the machine would use (agent options on the first request, the logged - * header afterwards); return a replacement to switch. On step admission, - * this runs after assembly and `step/start`, before the system prompt and - * accepted user batch are committed. Cancellation here or during subsequent - * `prepareCall()` resolution commits neither. The prepared call capability - * governs prompt admission. Model-visible content must use logged channels; - * this waterfall cannot mutate messages. - * @param payload.agent - the agent making the model call. - * @param payload.turn - the open turn number. - * @param payload.step - the step whose request this is. - * @param payload.signal - the current turn's explicit abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall -*/ -'agent/request'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise -``` - -Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md) - -Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - #### `agent/request-error` — waterfall diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index cf3f9ab1d1..db4bb206f5 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -65,10 +65,12 @@ interface AgentHandle { interface Agent { /** Session-backed Agent identity. */ readonly id: SessionId - /** The provider route and model this agent's requests use. */ + /** Route fallback and output controls; live accessors apply until the Session records a selection or request. */ readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** Agent-owned access to durable model selection for future steps. */ + readonly model: Model /** Agent-owned access to durable pending work. */ readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ @@ -197,20 +199,86 @@ type AssistantStreamFrame = `running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。dispose 会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`followup()` 不返回句柄:其 `MessageId` 标识的是持久的 inbox 插入、认领与丢弃事实,而非之后的助手输出或轮次结束。`whenIdle()` 观察的是整个 agent,因此只有当调用方明确拥有从回执到空闲的这段区间时,才能把它称为一次 run([决策](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md))。 ```ts type-equiv -/** Merge-extensible agent creation options. Persona belongs to system-prompt sections. */ +/** + * Merge-extensible agent options. Route fields may be live accessors until the + * Session records a selection or request; step preparation captures their value. + * Persona belongs to system-prompt sections. + */ interface AgentOptions { /** Provider route (must have a registered adapter at call time). */ provider?: string /** Model id interpreted by the selected provider adapter. */ model?: string /** Adapter-owned reasoning effort for the selected provider/model route. */ - reasoningEffort?: ReasoningEffortId + reasoningEffort?: ReasoningEffortId | undefined /** Maximum output tokens for each conversation-model request. */ maxTokens?: number } ``` -在 `agent/request` 之后,分发要求 `provider` 与 `model` 都存在。显式 `reasoningEffort` 会为该路由的首次请求提供初始值;确切模型解析会校验该值,省略时则允许填入适配器默认值。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona-prefix` 提示词段落可以遮蔽全局默认 persona。 +步骤准备在提示词组装前要求 `provider` 与 `model` 都存在。已弃用的 `agent/request` hook 可以更改请求控制参数,但必须保留该路由。显式 `reasoningEffort` 会为该路由的首次请求提供初始值;确切模型解析会校验该值,省略时则允许填入适配器默认值。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona-prefix` 提示词段落可以遮蔽全局默认 persona。 + +`AgentLoop` 在 `ctx.sessionProjections` 上注册 `modelSelection`,并通过每个 `agent.model` 实例提供选择操作。`model/selection` 记录完整选择,但不进入模型历史。匹配的请求头会清除 `pending`,最近请求仍保留为 `lastUsed`。适配器默认的推理强度会被省略,使后续请求重新解析默认值。只有尚未记录选择或请求时,`next` 才为 null,此时由 `AgentOptions` 提供路由。[Agent 模型 API](../../packages/core/agent/README.zh.md)说明选择优先级与恢复策略。 + +```ts type-equiv +/** Agent-owned access to the model choice for future steps. */ +interface Model { + /** + * Resolve the choice for the next step to begin preparation. Recorded choices + * take precedence over the last request and then the Agent's live options. + * This read neither starts a step nor changes an already-prepared step. + * @returns a detached choice, or undefined when no source names both provider and model. + */ + current(): Readonly | undefined + + /** + * Replace the next step's complete selection. Omitted effort follows adapter + * defaults without inheriting a previous effort or resolving defaults here. + * An equal recorded choice is a no-op; an options-only fallback is + * always recorded. Callers own model availability and capability validation. + * The append uses the Session's existing persistence and failure guarantees. + * @param selection - complete provider, model, and optional explicit effort. + */ + select(selection: Readonly): void + +} +``` + +```ts type-equiv +/** Complete provider, model, and optional reasoning effort chosen for one Session's model steps. */ +interface ModelSelection { + /** Registered provider route. */ + provider: string + /** Provider-owned model id. */ + model: string + /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ + reasoningEffort?: ReasoningEffortId +} +``` + +```ts type-equiv +/** + * Durable model-selection state for one Session. An effort the adapter + * defaulted on a recorded request is omitted, so later requests resolve the + * default again instead of pinning it as a choice. + */ +interface ModelSelectionProjectionState { + /** Selection used by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null + /** Later recorded selection not yet used by a matching model request. */ + readonly pending: ModelSelection | null +} +``` + +```ts type-equiv +/** Client view of the durable model-selection fold. */ +interface ModelSelectionProjection { + /** Selection used by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null + /** Selection the next model step uses; null while the Agent's initial options supply the route. */ + readonly next: ModelSelection | null +} +``` inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待处理消息列表: @@ -988,46 +1056,17 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. * @param payload.signal - the current turn's cancellation signal. + * @param payload.modelSelection - captured step selection, retained across retries; omitted effort uses adapter defaults. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal; modelSelection: Readonly }, next: () => Promise): Promise ``` Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - - -#### `agent/request` — waterfall - -Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. On step admission, this runs after assembly and `step/start`, before the system prompt and accepted user batch are committed. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages. - -```ts cordis-catalog -/** - * Replace the frozen call configuration. `await next()` yields the config - * the machine would use (agent options on the first request, the logged - * header afterwards); return a replacement to switch. On step admission, - * this runs after assembly and `step/start`, before the system prompt and - * accepted user batch are committed. Cancellation here or during subsequent - * `prepareCall()` resolution commits neither. The prepared call capability - * governs prompt admission. Model-visible content must use logged channels; - * this waterfall cannot mutate messages. - * @param payload.agent - the agent making the model call. - * @param payload.turn - the open turn number. - * @param payload.step - the step whose request this is. - * @param payload.signal - the current turn's explicit abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall -*/ -'agent/request'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise -``` - -Types: [LlmCallConfig](llm-streaming.zh.md) · [Scoped](scope.zh.md) - -Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - #### `agent/request-error` — waterfall diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 0fd7190488..368a82dc27 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -39,8 +39,8 @@ en: 2b0831aaae8e1cdf zh: f669d93d1b9d6025 /llm-streaming/the-model-request/the-request-envelope-llmcallconfig-and-the-logged-header: - en: f2e6932e211f179b - zh: 75112586a6f3f4aa + en: b0eb4620c6953646 + zh: fabf51c7245a4855 /llm-streaming/official-deepseek-request-extensions: en: 42bc50394b070e96 zh: 007caa743ec3c790 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 1f652c1ee4..23b7c6c2bd 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -760,7 +760,7 @@ interface LlmDiscoveredModel { The loop builds each request from logged state. `EpochHeader` records call config, marks the fields supplied by adapter defaults, and records the authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. The rendered prompt is derived history — the `system/message` at surface node 0, plus any later system node an `in-history` route appended — so the header and the derived history together make the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. On step admission, this waterfall and preparation run after assembly and `step/start` but before the system prompt and accepted user batch are committed; cancellation during either commits neither. The prepared capability governs prompt reconciliation, and the call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +The deprecated `agent/request` hook remains available to existing listeners. New code selects models and reasoning effort through [Session model selection](../../packages/core/agent/README.md#use-this-package). The hook receives a frozen call-config seed based on the step's captured model selection and may replace controls such as sampling. A replacement must preserve provider, model, and the optional explicit reasoning effort; record a new Session selection before the next step to change them. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. On step admission, this waterfall and preparation run after assembly and `step/start` but before the system prompt and accepted user batch are committed; cancellation during either commits neither. The prepared capability governs prompt reconciliation, and the call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. On the wire, a loop-built request is the derived history alone: the rendered prompt travels as the leading `system`-role message (surface node 0, a `system/message` event) and, when the prepared call declares `systemPromptUpdate: 'in-history'`, a non-empty changed prompt may follow the cached history as a later `system`-role message that the model reads as the effective prompt; the request's `system` field is unset — `GenerateOptions.system` serves direct one-shot callers such as title providers. An empty rendering leaves no system messages in derived history, even when earlier requests retained several prompt versions. The logged request ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 410b6eb2b6..98783a10e1 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -766,7 +766,7 @@ interface LlmDiscoveredModel { 循环从已记录状态构建每个请求。`EpochHeader` 记录调用配置,标记由适配器默认值提供的字段,并通过完整的 `request/header` 快照记录权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。渲染后的提示词是派生历史——surface 第 0 号节点上的 `system/message`,加上 `in-history` 路由追加的任何后续系统节点——因此请求头与派生历史共同使请求可由会话日志重建。见 [session.md](session.zh.md#the-request-header-event-requestheader) 与[可重建性 Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。步骤准入时,该 waterfall 与准备过程在组装和 `step/start` 之后、系统提示词与已接纳用户批次提交之前运行;在任一阶段取消都不会提交这两者。已准备调用的能力决定提示词协调,调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +已弃用的 `agent/request` hook 仍可供现有监听器使用。新代码通过 [Session 模型选择](../../packages/core/agent/README.zh.md#use-this-package) 设置模型与推理强度。该 hook 收到基于步骤已捕获模型选择的冻结调用配置初值,可以替换采样等控制参数。替换值必须保留提供方、模型及可选的显式推理强度;要改变这些选择,需在下一个步骤之前记录新的 Session 选择。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。步骤准入时,该 waterfall 与准备过程在组装和 `step/start` 之后、系统提示词与已接纳用户批次提交之前运行;在任一阶段取消都不会提交这两者。已准备调用的能力决定提示词协调,调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 在协议中,循环构建的请求只有派生历史:渲染后的提示词作为开头的 `system` 角色消息(surface 第 0 号节点,即一个 `system/message` 事件)传输,并且当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,变化后的非空提示词可以作为后续的 `system` 角色消息跟在已缓存历史之后,由模型读作有效提示词;请求的 `system` 字段不设置——`GenerateOptions.system` 服务于标题提供方等直接单次调用方。空渲染文本使派生历史不包含任何系统消息,即使先前请求保留了多个提示词版本。已记录的请求会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index a2567a6cc3..117881640a 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -205,20 +205,21 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con /** * Snapshot all references for one accepted direct message and return one aggregated durable context. - * Automatic budgets use the last assembled route, or agent options before any assembly. + * Automatic budgets use the context capacity of `route`. * Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation. * Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice. * Cancellation prevents context publication, including when storage completes after cancellation. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. + * @param route - provider and model whose context capacity sizes the previews. * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ -async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise +async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], route: Readonly>, signal?: AbortSignal, ): Promise ``` -Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) +Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [LlmCallConfig](llm-streaming.md) Source: [`packages/context/session-reference/src/index.ts`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 43cd567aaa..9b150be4e2 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -205,20 +205,21 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con /** * Snapshot all references for one accepted direct message and return one aggregated durable context. - * Automatic budgets use the last assembled route, or agent options before any assembly. + * Automatic budgets use the context capacity of `route`. * Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation. * Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice. * Cancellation prevents context publication, including when storage completes after cancellation. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. + * @param route - provider and model whose context capacity sizes the previews. * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ -async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise +async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], route: Readonly>, signal?: AbortSignal, ): Promise ``` -Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) +Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) · [LlmCallConfig](llm-streaming.zh.md) Source: [`packages/context/session-reference/src/index.ts`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index e15901b657..998aebbffc 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -137,6 +137,7 @@ Factory: conversation.content [ConversationContent] ├─ conversation.session [ConversationSession / DefaultConversationViews] │ └─ conversation.view │ ├─ id=chat [ChatView] +│ │ ├─ conversation.chat.text-interaction [list] │ │ └─ conversation.chat.flow [ChatFlow] │ │ └─ conversation.chat.node [ChatNodeSeat dispatch] │ │ ├─ key=user,steering [UserMessageNodeView] diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 46e5f1b04e..e2d9217ec6 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -139,6 +139,7 @@ Factory: conversation.content [ConversationContent] ├─ conversation.session [ConversationSession / DefaultConversationViews] │ └─ conversation.view │ ├─ id=chat [ChatView] +│ │ ├─ conversation.chat.text-interaction [list] │ │ └─ conversation.chat.flow [ChatFlow] │ │ └─ conversation.chat.node [ChatNodeSeat dispatch] │ │ ├─ key=user,steering [UserMessageNodeView] diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index 9c893ee1a7..acd90cbb02 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -6,8 +6,8 @@ en: 7ad02b2146520e7b zh: 93cb93c7aa12ceb4 /system-prompt-assembly/assembly-context: - en: 371539c21c1ffc45 - zh: ea429ea5a0c19215 + en: a5d904293afc7fcb + zh: 555883610ac09646 /system-prompt-assembly/tool-provider-result: en: 89cb015bf2e3e7a5 zh: b4340e853b34c777 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index c529c150bb..c87334dcd2 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -8,7 +8,7 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system- ## Assembly context -`AssembleContext` identifies the scope layer one assembly resolves and may carry the explicit control signal for that request. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent, signal)` sets the explicit fields together. A bare assembly has neither scope nor signal. +`AssembleContext` identifies the scope layer one assembly resolves and may carry the explicit control signal for that request. It is merge-extensible: `dsh-agent` adds optional `agent` and `modelSelection` fields. `assembleContextFor(agent, signal)` sets the agent, scope, and signal; the loop supplies the model selection captured for the step and retained across retries, with omitted reasoning effort using adapter defaults. A bare assembly has neither scope nor signal. ```ts type-equiv /** Merge-extensible context for one prompt assembly. */ diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index a805c1d771..6ca4ca97e8 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -8,7 +8,7 @@ ## 组装上下文 -`AssembleContext` 标识一次组装所解析的作用域层,并可携带该请求的显式控制信号。它可合并扩展:`dsh-agent` 添加可选字段 `agent`,用于携带当前的 agent(智能体)实例;`assembleContextFor(agent, signal)` 则一起设置这些显式字段。裸组装既没有作用域,也没有信号。 +`AssembleContext` 标识一次组装所解析的作用域层,并可携带该请求的显式控制信号。它可合并扩展:`dsh-agent` 添加可选字段 `agent` 和 `modelSelection`。`assembleContextFor(agent, signal)` 设置 agent(智能体)、作用域和信号;循环提供为该步骤捕获并在重试期间保留的模型选择,省略推理强度时使用适配器默认值。裸组装既没有作用域,也没有信号。 ```ts type-equiv /** Merge-extensible context for one prompt assembly. */ diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.i18n.yaml new file mode 100644 index 0000000000..581e852217 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.md +/: + en: 3890f393c1af5500 + zh: 64fce473a9f35fae +/session-model-selection-applies-at-the-next-step: + en: ac99540fa45af789 + zh: 68b33ea8d0063615 +/session-model-selection-applies-at-the-next-step/change: + en: 5ab837106ca3bd0c + zh: 5a6d2e65f3cdb191 +/session-model-selection-applies-at-the-next-step/migration: + en: bf4f6d902cfe5a78 + zh: f19cb24e45d50dc4 diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.md b/docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.md new file mode 100644 index 0000000000..51a00bb86b --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.md @@ -0,0 +1,28 @@ +--- +kind: upgrade-guide +description: "Session model choices are fixed at step preparation, and projection types move to their owning package." +--- + +# Session model selection applies at the next step + +English | [中文](guide.zh.md) + +## Change + +ACP model and reasoning changes take effect at the next model step, including a tool continuation in the current turn. Previously, ACP pinned the selection for the complete prompt turn. A prepared step and its retries retain their captured selection. Image admission checks the route selected when admission begins; a later option change can select a different route before the step starts. + +`AgentLoop` owns the durable `modelSelection` projection and constructs an `AgentModel` instance on each Agent’s `model` property. The API Session controller no longer registers it. Programmatic resume restores recorded choices, including unused selections, before falling back to `AgentOptions`. Configured Agent-loop resumes, headless adoption, and SDK resume record their deployment route; an omitted effort preserves saved explicit effort only for the same route. + +Plugins must select provider, model, and optional explicit reasoning effort before prompt assembly. `agent/request` is deprecated. Existing listeners still run on each main-loop request attempt and can adjust other controls, but cannot change provider or model, or add, change, or clear reasoning effort. Absent effort still resolves through adapter defaults on each attempt. Auxiliary calls such as compaction do not dispatch this event. `installModelSelection` now installs only the switch-notice listener; its selection argument and `ModelSelectionRef` are removed. + +The client projection and Host state types move to `dsh-agent/types`; the API package no longer exports them. + +## Migration + +1. In custom Cordis compositions, mount `dsh-session-projection` with `dsh-agent-loop`. The loop constructs `agent.model`; consumers need no separate model-selection service. +2. Replace mutable references and selection hooks with `agent.model.select(selection)` after validation. Read with `current()`. Keep `installModelSelection(agentCtx)` in Agent setup for switch notices and remove its old selection argument. Do not add `agent/request` listeners. +3. Set the output cap through `AgentOptions.maxTokens`. Other per-request control overrides have no replacement API; retain an existing listener where that behavior is still required. +4. To override a saved route during programmatic resume, merge the configured selection with the saved explicit effort only when the route is unchanged and the configuration omits effort, then call `agent.model.select(selection)` in the Agent setup callback. Supplying `agentOptions` alone provides only a fallback for a Session without a selection or request. +5. Pass the captured `Readonly` in `agent/pre-step.modelSelection`, including explicit effort when selected; `AssembleContext.modelSelection` carries the same selection. Direct `SessionReferenceResolver.prepare()` callers pass the route as the fourth argument, before the optional signal. +6. ACP controllers that require one model throughout a turn must wait for the active prompt to finish before changing its options. Verify that a mid-turn change is recorded as `model/selection`, the next step uses it, and resume retains an unused choice. +7. Import `ModelSelectionProjection` from `@deepseek-ai/dsh-agent/types`, replacing API package aliases. `ModelSelection` remains in `dsh-agent/types`. Import `ModelSelectionProjectionState` from `@deepseek-ai/dsh-agent` only when implementing a driver; ordinary reads use `agent.model.current()`. Effort values use `ReasoningEffortId` from `dsh-llm/brand`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.zh.md new file mode 100644 index 0000000000..31a7ffc04e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/session-model-selection/guide.zh.md @@ -0,0 +1,28 @@ +--- +kind: upgrade-guide +description: "Session 模型选择在步骤准备时固定,投影类型移至其所属包。" +--- + +# Session 模型选择从下一个步骤开始生效 + +[English](guide.md) | 中文 + +## 变更 + +ACP(Agent Client Protocol)的模型与推理强度变更从下一个模型步骤开始生效,包括当前轮次的工具后续步骤。此前,ACP 会在整个提示词轮次中固定选择。已准备的步骤及其重试保留捕获的选择。图片准入检查准入开始时选择的路由;后续选项变更可能在步骤开始前选中另一条路由。 + +`AgentLoop` 拥有持久 `modelSelection` 投影,并为每个 Agent 的 `model` 属性构建一个 `AgentModel` 实例。API Session 控制器不再注册该投影。程序化恢复优先还原记录中的选择,包括尚未使用的选择,然后才回退到 `AgentOptions`。配置式 Agent-loop 恢复、headless 接管与 SDK 恢复会记录各自的部署路由;省略推理强度时,仅在路由相同的情况下保留已保存的显式强度。 + +插件必须在提示词组装前选定提供方、模型及可选的显式推理强度。`agent/request` 已弃用。现有监听器仍在主循环的每次请求尝试中运行,可以调整其他控制参数,但不能改变提供方或模型,也不能添加、修改或清除推理强度。未指定的强度仍在每次尝试时通过适配器默认值解析。压缩等辅助调用不会分发该事件。`installModelSelection` 现在只安装切换提示监听器;其选择参数与 `ModelSelectionRef` 已移除。 + +客户端投影与 Host 状态类型移至 `dsh-agent/types`;API 包不再导出这些类型。 + +## 迁移 + +1. 在自定义 Cordis 组合中将 `dsh-session-projection` 与 `dsh-agent-loop` 一起挂载。循环构建 `agent.model`;消费方不需要独立的模型选择服务。 +2. 将可变引用与选择 hook 替换为校验后的 `agent.model.select(selection)`,使用 `current()` 读取选择。在 Agent setup 中保留 `installModelSelection(agentCtx)` 以提供切换提示,并移除原来的选择参数。不要添加 `agent/request` 监听器。 +3. 通过 `AgentOptions.maxTokens` 设置输出上限。其他逐请求控制参数覆盖没有替代 API;仍需要该行为时,保留现有监听器。 +4. 要在程序化恢复时覆盖已保存路由,先形成配置选择,仅在路由不变且配置省略强度时保留已保存的显式强度,再在 Agent setup 回调中调用 `agent.model.select(selection)`。仅提供 `agentOptions` 时,它只为没有选择或请求记录的 Session 提供回退值。 +5. 通过 `agent/pre-step.modelSelection` 传入捕获的 `Readonly`,包含已选定的显式强度;`AssembleContext.modelSelection` 携带同一选择。直接调用 `SessionReferenceResolver.prepare()` 时,将路由作为第四个参数,放在可选信号之前。 +6. 要求整轮使用同一个模型的 ACP 控制器,必须等待活跃提示词完成后再更改选项。验证轮次中的变更被记录为 `model/selection`、下一个步骤使用该选择,且恢复保留尚未使用的选择。 +7. 从 `@deepseek-ai/dsh-agent/types` 导入 `ModelSelectionProjection`,替换 API 包别名。`ModelSelection` 仍位于 `dsh-agent/types`。仅在实现驱动器时从 `@deepseek-ai/dsh-agent` 导入 `ModelSelectionProjectionState`;普通读取使用 `agent.model.current()`。强度值使用 `dsh-llm/brand` 中的 `ReasoningEffortId`。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.i18n.yaml new file mode 100644 index 0000000000..6db67f4529 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.md +/: + en: ffbf3ccb8ac486ea + zh: 1712a76e8e3a664a +/cua-relative-coordinates-use-0-999: + en: 1952d155be13e0d1 + zh: 01b2f9f27499f033 +/cua-relative-coordinates-use-0-999/change: + en: 311311c2056c8e93 + zh: 63f4dc87c9d22f99 +/cua-relative-coordinates-use-0-999/migration: + en: de5b600f1d9ae83c + zh: e2de8ebd5c7ca2bb diff --git a/docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.md new file mode 100644 index 0000000000..09e5ffbdea --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "CUA relative coordinates accept integers from 0 to 999 and reject the former endpoint 1000." +--- + +# CUA relative coordinates use 0–999 + +English | [中文](guide.zh.md) + +## Change + +The `cua` tool and application/window object methods now accept integers from 0 to 999 when `relative: true`. The previous range was 0 to 1000. Coordinates still use thousandths: divide by 1000, scale to window dimensions, and clamp inside the window. Values from 0 to 999 keep their existing positions. This affects `click`, `hover`, `scroll`, and both `drag` endpoints. Absolute screenshot pixels and AX element indices are unchanged. + +## Migration + +1. Update scripts and prompts that generate relative coordinates. Keep existing values from 0 to 999 unchanged. Replace the old endpoint 1000 with 999, for example with `Math.min(oldValue, 999)` for valid old coordinates. On larger windows, 999 can land farther inside than the former endpoint 1000. +2. Read fresh window state, then invoke the affected action with integer coordinates in 0–999. Verify the result with a fresh observation. Relative values of 1000, negative values, and fractions are rejected before native input. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.zh.md new file mode 100644 index 0000000000..0a250edbbc --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/cua-relative-coordinates/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "CUA 相对坐标接受 0~999 的整数,并拒绝原端点 1000。" +--- + +# CUA 相对坐标使用 0~999 + +[English](guide.md) | 中文 + +## 变更 + +`cua` 工具和应用或窗口对象方法在 `relative: true` 时接受 0~999 的整数,原范围为 0~1000。坐标仍使用千分比:除以 1000 后按窗口尺寸缩放,并限制在窗口内。0~999 的坐标保持原有落点。这影响 `click`、`hover`、`scroll` 和 `drag` 的起终点。绝对截图像素和 AX 元素编号保持原有行为。 + +## 迁移 + +1. 更新生成相对坐标的脚本和提示。0~999 的现有数值保持不变。将旧端点 1000 改为 999,例如对原本合法的旧坐标使用 `Math.min(oldValue, 999)`。在较大的窗口中,999 可能比原端点 1000 更靠内。 +2. 读取新的窗口状态后,使用 0~999 的整数坐标调用相应动作,再读取新状态验证结果。相对坐标为 1000、负数或小数时,会在原生输入前被拒绝。 diff --git a/docs/user/develop/framework/events.i18n.yaml b/docs/user/develop/framework/events.i18n.yaml index 871dfbca64..62efaa9a9d 100644 --- a/docs/user/develop/framework/events.i18n.yaml +++ b/docs/user/develop/framework/events.i18n.yaml @@ -33,8 +33,8 @@ en: 18070de55501a230 zh: 0a34d22dc2662599 /event-system/cordis-events-and-session-records: - en: 93102b27250eea35 - zh: a97a0197f785a4aa + en: 866ef8c0f99b2e8a + zh: 14c9b69b27ffd850 /event-system/event-listeners-are-effects: en: b7cc96734661caef zh: 442ec0da611cf644 diff --git a/docs/user/develop/framework/events.md b/docs/user/develop/framework/events.md index c8c1bc753a..753b9ba22e 100644 --- a/docs/user/develop/framework/events.md +++ b/docs/user/develop/framework/events.md @@ -101,7 +101,7 @@ declare module '@deepseek-ai/cordis' { ## Cordis events and session records -Harness Cordis events use `namespace/action` names, including `agent/pre-step`, `agent/request`, `agent/request-error`, `tools/result`, and `session/event`. The generated `cordis-surface` regions on the [subsystem pages](../../../subsystems/core.md) record complete signatures and modes. +Harness Cordis events use `namespace/action` names, including `agent/pre-step`, `agent/request-error`, `tools/result`, and `session/event`. The generated `cordis-surface` regions on the [subsystem pages](../../../subsystems/core.md) record complete signatures and modes. `turn/*`, `step/*`, `tool/call`, `tool/result`, and `compaction/*` are durable session-event types, not same-named Cordis events. To observe them, listen to `session/event` and inspect `event.type`. diff --git a/docs/user/develop/framework/events.zh.md b/docs/user/develop/framework/events.zh.md index 366c37ca4d..e25f497105 100644 --- a/docs/user/develop/framework/events.zh.md +++ b/docs/user/develop/framework/events.zh.md @@ -101,7 +101,7 @@ declare module '@deepseek-ai/cordis' { ## Cordis 事件与会话记录 -Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/request-error`、`tools/result` 和 `session/event`。完整签名与触发模式见[子系统页面](../../../subsystems/core.zh.md)上生成的 `cordis-surface` 区块。 +Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request-error`、`tools/result` 和 `session/event`。完整签名与触发模式见[子系统页面](../../../subsystems/core.zh.md)上生成的 `cordis-surface` 区块。 `turn/*`、`step/*`、`tool/call`、`tool/result` 和 `compaction/*` 是持久化的会话事件类型,不是同名 Cordis 事件。需要观察它们时,监听 `session/event` 并检查 `event.type`。 diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 68c18de182..aca817ae8a 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -27,8 +27,8 @@ en: 4ed8e37834267edb zh: a32d5dedb4e8e919 /deepseek-ai-dsh-acp/use-this-package/protocol-contract: - en: 29099e63f9a173c6 - zh: 80d98e87f442d335 + en: 8f358b006f93f6ab + zh: a02dc9640cac4edd /deepseek-ai-dsh-acp/understand-the-implementation: en: ea8afe89dbdd1934 zh: 6567979f92f34afd diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index fa713b872f..44a63a1da2 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -77,7 +77,7 @@ A prompt remains in flight while managed descendants run, while their results tr A failure to append `turn/start` or `turn/end` rejects the prompt even when a preceding turn completed. -Session configuration offers opaque provider/model choices from the live LLM service catalog and a `reasoning_effort` selector when the exact model declares one. A prompt snapshots that selection before asynchronous image admission and pins it across every model step in that turn; a concurrent option change applies to the next turn. ACP clients are trusted controllers: stdio MCP entries authorize their absolute commands and environment, HTTP entries authorize their absolute HTTP(S) URLs and headers, and any initial connection or discovery failure rolls back the unpublished Agent. Unsupported surfaces are omitted or rejected: `session/load`, deletion, fork, additional directories, SSE or ACP-transport MCP, modes, commands, plans, terminals, client filesystem operations, and elicitation. +Session configuration offers opaque provider/model choices from the live LLM service catalog and a `reasoning_effort` selector when the exact model declares one. An option change records the Session selection and takes effect at the next model step, including a continuation in the current turn; an already prepared step and its retries keep their captured selection. Image admission checks the selection current when admission begins. Resume restores a recorded selection even when no request has used it yet. ACP clients are trusted controllers: stdio MCP entries authorize their absolute commands and environment, HTTP entries authorize their absolute HTTP(S) URLs and headers, and any initial connection or discovery failure rolls back the unpublished Agent. Unsupported surfaces are omitted or rejected: `session/load`, deletion, fork, additional directories, SSE or ACP-transport MCP, modes, commands, plans, terminals, client filesystem operations, and elicitation. ----- diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index e62e154191..500c90f5d2 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -77,7 +77,7 @@ kind: "package-reference" 即使前一轮已经完成,追加 `turn/start` 或 `turn/end` 失败仍会使提示词请求失败。 -会话配置从实时 LLM(大语言模型)服务目录提供不透明的提供方/模型选项,并在确切模型声明推理选项时提供 `reasoning_effort`。提示词会在异步图片准入前快照该选择,并在该轮次的每个模型步骤中固定它;并发选项变更从下一轮次开始生效。ACP 客户端是受信控制器:stdio MCP 条目授权其绝对命令与环境,HTTP 条目授权其绝对 HTTP(S) URL 与 header;初始连接或发现失败会回滚尚未发布的 Agent。不支持的界面会被省略或拒绝:`session/load`、删除、fork、附加目录、SSE(Server-Sent Events)或 ACP 传输 MCP、mode、命令、计划、终端、客户端文件系统操作与 elicitation。 +会话配置从实时 LLM(大语言模型)服务目录提供不透明的提供方/模型选项,并在确切模型声明推理选项时提供 `reasoning_effort`。选项变更记录 Session 选择,从下一个模型步骤开始生效,包括当前轮次的后续步骤;已准备的步骤及其重试保留捕获的选择。图片准入检查准入开始时的当前选择。恢复会还原记录中的选择,即使尚未有请求使用它。ACP 客户端是受信控制器:stdio MCP 条目授权其绝对命令与环境,HTTP 条目授权其绝对 HTTP(S) URL 与 header;初始连接或发现失败会回滚尚未发布的 Agent。不支持的界面会被省略或拒绝:`session/load`、删除、fork、附加目录、SSE(Server-Sent Events)或 ACP 传输 MCP、mode、命令、计划、终端、客户端文件系统操作与 elicitation。 ----- diff --git a/packages/acp/acp/src/content.ts b/packages/acp/acp/src/content.ts index e31807b276..ff9f147f3f 100644 --- a/packages/acp/acp/src/content.ts +++ b/packages/acp/acp/src/content.ts @@ -114,7 +114,7 @@ function resourceLinkText(block: Extract this.turnSelection?.selection ?? this.selected - const setCurrent = (value: ModelSelection | undefined): void => { this.selected = value } - this.selection = { - get current() { return getCurrent() }, - set current(value) { setCurrent(value) }, - assembled: undefined, - } - } - - /** - * Install request/prompt consistency listeners in the unpublished Agent scope. - * @param agentCtx - Agent scope that consumes this selection. - */ - install(agentCtx: Context): void { - installModelSelection(agentCtx, this.selection) - } - - /** - * Snapshot the selection attached to the next accepted ACP prompt. - * @returns a detached future selection, or undefined when listeners supply the route. - */ - snapshot(): ModelSelection | undefined { - return this.selected === undefined ? undefined : { ...this.selected } - } - - /** - * Pin one admitted ACP message's selection for every step in its turn. - * @param turn - admitted Agent turn. - * @param selection - exact prompt-admission selection. - */ - pinTurn(turn: number, selection: ModelSelection): void { - this.turnSelection = { turn, selection: { ...selection } } - } - - /** - * Release only the exact completed turn's routing override. - * @param turn - completed Agent turn. - */ - releaseTurn(turn: number): void { - if (this.turnSelection?.turn === turn) this.turnSelection = undefined - } + private readonly model: Model, + ) {} /** * Return the complete standard config-option state after prior mutations settle. @@ -103,14 +60,14 @@ export class AcpModelControl { set(configId: string, value: unknown, signal?: AbortSignal): Promise { return this.serialize(async () => { if (typeof value !== 'string') throw new AcpModelConfigError(`${configId} requires a select value`) - const current = this.selected + const current = this.model.current() if (current === undefined) throw new AcpModelConfigError('this session has no model selection') if (configId === MODEL_CONFIG_ID) { const state = await this.state(signal) const selected = state.choices.get(value) if (selected === undefined) throw new AcpModelConfigError(`unknown model option: ${value}`) await this.resolveSelection(selected, signal) - this.selected = selected + this.model.select(selected) } else if (configId === REASONING_CONFIG_ID) { const info = await this.llm.resolveModelInfo(current.provider, current.model, signal) const providerDefault = value === PROVIDER_DEFAULT_REASONING_VALUE @@ -121,11 +78,11 @@ export class AcpModelControl { ) { throw new AcpModelConfigError(`unknown reasoning effort for ${current.provider}/${current.model}: ${value}`) } - this.selected = await this.resolveSelection({ + this.model.select(await this.resolveSelection({ provider: current.provider, model: current.model, ...providerDefault ? {} : { reasoningEffort: ReasoningEffortId(value) }, - }, signal) + }, signal)) } else { throw new AcpModelConfigError(`unknown session config option: ${configId}`) } @@ -142,7 +99,7 @@ export class AcpModelControl { /** Build detached model choices and the dependent reasoning option. */ private async state(signal?: AbortSignal): Promise { - const selected = this.selected + const selected = this.model.current() if (selected === undefined) return { choices: new Map(), options: [] } let resolved: ModelSelection let routeAvailable = true diff --git a/packages/acp/acp/src/session.ts b/packages/acp/acp/src/session.ts index 9b6ee8fd10..2af289b3a2 100644 --- a/packages/acp/acp/src/session.ts +++ b/packages/acp/acp/src/session.ts @@ -10,7 +10,8 @@ import { type SessionNotification, type StopReason, } from '@agentclientprotocol/sdk' -import type { Agent, AgentHandle, AgentOptions, AgentStatus, ModelSelection } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' +import { installModelSelection } from '@deepseek-ai/dsh-agent' import { createUserMessage, errorChain, type UserMessage } from '@deepseek-ai/dsh-llm' import { type Session, type SessionEvent, type SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import { AcpContentError, admitAcpPrompt } from './content.ts' @@ -32,7 +33,6 @@ interface AcpSessionBuildOptions { cwd: string mcpServers: readonly McpServer[] agentOptions: AgentOptions - fallbackSelection: ModelSelection | undefined signal: AbortSignal notify: (notification: SessionNotification) => Promise } @@ -74,29 +74,20 @@ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) } -/** Restore the latest logged route before falling back to deployment config. */ -function selectionFor( - logged: { - config: { provider: string; model: string; reasoningEffort?: ModelSelection['reasoningEffort'] } - adapterDefaults?: { reasoningEffort?: boolean } - } | undefined, - fallback: ModelSelection | undefined, -): ModelSelection | undefined { - return logged === undefined - ? fallback - : { - provider: logged.config.provider, - model: logged.config.model, - ...logged.config.reasoningEffort === undefined || logged.adapterDefaults?.reasoningEffort === true - ? {} - : { reasoningEffort: logged.config.reasoningEffort }, - } +/** + * Install switch notices and mount the requested MCP clients in the unpublished Agent scope. + * @param agentCtx - unpublished Agent scope. + * @param options - workspace and MCP servers requested for the session. + */ +async function composeAgent(agentCtx: Context, options: AcpSessionBuildOptions): Promise { + installModelSelection(agentCtx) + await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd) } /** - * Per-session ACP module. It owns the unpublished Agent composition, selected - * route, one-prompt admission slot, ordered standard updates, and memoized - * quiescent teardown. + * Per-session ACP module. It owns the unpublished Agent composition, model + * configuration view, one-prompt admission slot, ordered standard updates, + * and memoized quiescent teardown. */ export class AcpSession { /** The exact top-level Agent owned by this ACP session. */ @@ -105,16 +96,14 @@ export class AcpSession { private outputTail = Promise.resolve() private inflight: InflightPrompt | undefined private closing: Promise | undefined - private readonly pendingSelections = new Map() private constructor( private readonly ctx: Context, handle: AgentHandle, - modelControl: AcpModelControl, private readonly notify: (notification: SessionNotification) => Promise, ) { this.agent = handle.agent - this.modelControl = modelControl + this.modelControl = new AcpModelControl(ctx.llm, handle.agent.model) this.disposeAgent = () => handle.dispose() } @@ -127,48 +116,32 @@ export class AcpSession { * @returns the fully composed per-session module. */ static async create(ctx: Context, options: CreateAcpSessionOptions): Promise { - const modelControl = new AcpModelControl(ctx.llm, options.fallbackSelection) const handle = await ctx.agents.create({ sessionId: options.sessionId, meta: { cwd: options.cwd }, agentOptions: options.agentOptions, signal: options.signal, - setup: async (agentCtx) => { - modelControl.install(agentCtx) - await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd) - }, + setup: agentCtx => composeAgent(agentCtx, options), }) - return new AcpSession(ctx, handle, modelControl, options.notify) + return new AcpSession(ctx, handle, options.notify) } /** * Restore a persisted Agent and compose the request's fresh MCP connections. + * The Session's recorded model selection survives; deployment options supply + * the route only when the Session records neither a selection nor a request. * @param ctx - ACP plugin context with Agent, LLM, and persistence services. - * @param options - persisted identity, workspace, fallback route, MCP, and notifier. + * @param options - persisted identity, workspace, initial route, MCP, and notifier. * @returns the restored per-session module. */ static async resume(ctx: Context, options: ResumeAcpSessionOptions): Promise { - let modelControl: AcpModelControl | undefined const handle = await ctx.agents.resume({ resumeSessionId: options.sessionId, agentOptions: options.agentOptions, signal: options.signal, - setup: async (agentCtx, agent) => { - modelControl = new AcpModelControl( - ctx.llm, - selectionFor(agent.session.requestHeader(), options.fallbackSelection), - ) - modelControl.install(agentCtx) - await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd) - }, + setup: agentCtx => composeAgent(agentCtx, options), }) - /* v8 ignore start -- a fulfilled Agent resume necessarily ran setup to completion. */ - if (modelControl === undefined) { - await handle.dispose() - throw internalError('session/resume did not compose model selection') - } - /* v8 ignore stop */ - return new AcpSession(ctx, handle, modelControl, options.notify) + return new AcpSession(ctx, handle, options.notify) } /** @@ -200,7 +173,7 @@ export class AcpSession { } /** - * Apply one standard configuration option to later ACP turns. + * Apply one standard configuration option from the next model step. * @param configId - advertised standard option id. * @param value - selected standard option value. * @param signal - optional request cancellation. @@ -276,7 +249,7 @@ export class AcpSession { if (requestSignal?.aborted === true) onRequestAbort() try { let admissionFailure: unknown - const promptSelection = this.modelControl.snapshot() + const promptSelection = this.agent.model.current() try { if (this.ctx.agents.get(this.agent.id) !== this.agent) { throw internalError('prompt was not queued: the agent was disposed outside the bridge') @@ -298,12 +271,10 @@ export class AcpSession { }) inflight.messageId = message.id inflight.messageQueued = true - if (promptSelection !== undefined) this.pendingSelections.set(message.id, promptSelection) try { this.agent.followup(message) } catch (error: unknown) { inflight.messageQueued = false - this.pendingSelections.delete(message.id) throw error } } catch (error: unknown) { @@ -393,20 +364,16 @@ export class AcpSession { inflight.endReason = event.data.reason inflight.agentError = undefined } - if (event.type === 'turn/end') this.modelControl.releaseTurn(event.data.turn) } } /** - * Correlate an accepted user message with its Agent turn and pinned route. + * Correlate an accepted user message with its Agent turn. * @param message - claimed durable inbox message. * @param turn - allocated Agent turn. */ onInboxClaimed(message: UserMessage, turn: number): void { if (this.inflight !== undefined && this.inflight.messageId === message.id) this.inflight.turn = turn - const selection = this.pendingSelections.get(message.id) - this.pendingSelections.delete(message.id) - if (selection !== undefined) this.modelControl.pinTurn(turn, selection) } /** @@ -475,7 +442,6 @@ export class AcpSession { } catch (error: unknown) { failures.push(error) } - this.pendingSelections.clear() if (failures.length === 1) throw failures[0] /* v8 ignore start -- independent teardown failures can aggregate only under multiple simultaneous provider faults. */ if (failures.length > 1) { diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index c0f5d73f89..b0911a4f9b 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -314,6 +314,24 @@ describe('automation-only ACP bridge', () => { }) }) + it('restores a pending model selection without any request header', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.setSessionConfigOption({ + sessionId: created.sessionId, configId: 'model', value: '["mock","plain"]', + }) + const agent = harness.ctx.agents.get(SessionId(created.sessionId))! + expect(agent.session.requestHeader()).toBeUndefined() + expect(agent.session.snapshotEvents().some(event => event.type === 'model/selection')).toBe(true) + await harness.client.closeSession({ sessionId: created.sessionId }) + + const resumed = await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }) + expect(resumed.configOptions?.find(option => option.id === 'model')).toMatchObject({ + currentValue: '["mock","plain"]', + }) + }) + it('restores an explicitly selected reasoning effort', async () => { harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -667,7 +685,7 @@ describe('automation-only ACP bridge', () => { expect(harness.adapter.requests[0]?.reasoningEffort).toBeUndefined() }) - it('pins image admission and request routing to one prompt selection', async () => { + it('checks images against the admission route and uses the latest selection when the step starts', async () => { harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('image accepted')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -696,7 +714,7 @@ describe('automation-only ACP bridge', () => { releaseValidation.resolve(undefined) await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' }) - expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' }) + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'plain' }) harness.attachments!.beforeValidate = undefined await expect(harness.client.prompt({ sessionId: created.sessionId, @@ -704,7 +722,7 @@ describe('automation-only ACP bridge', () => { })).rejects.toThrow(/does not declare image input/) }) - it('applies a mid-turn model change to the following turn', async () => { + it('applies a mid-turn model change to the following step', async () => { harness = await makeBridgeHarness({ script: [oneToolCall(), textResponse('first turn'), textResponse('second turn')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -715,7 +733,7 @@ describe('automation-only ACP bridge', () => { if (plain === undefined) throw new Error('expected Mock Plain in the model catalog') harness.ctx.tools.register(defineContentToolFixture({ name: 'switch_model', - description: 'Switch the following turn to the plain model.', + description: 'Switch the following step to the plain model.', parameters: {}, execute: async () => { await harness!.client.setSessionConfigOption({ @@ -730,7 +748,7 @@ describe('automation-only ACP bridge', () => { await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] }) await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second' }] }) - expect(harness.adapter.requests.map(request => request.model)).toEqual(['mock', 'mock', 'plain']) + expect(harness.adapter.requests.map(request => request.model)).toEqual(['mock', 'plain', 'plain']) }) it('mounts a standard stdio MCP server inside the created session', async () => { @@ -834,20 +852,17 @@ describe('automation-only ACP bridge', () => { expect(harness.ctx.agents.get(SessionId(sessionId))?.options).toEqual({}) }) - it('allows request listeners to supply a route when ACP has no initial selection', async () => { + it('uses a recorded selection when ACP has no initial route', async () => { harness = await makeBridgeHarness({ config: { provider: undefined, model: undefined }, script: [textResponse('listener-routed')], }) - harness.ctx.on('agent/request', async (_payload, next) => ({ - ...await next(), - provider: 'mock', - model: 'mock', - })) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) expect(created.configOptions).toEqual([]) + const agent = harness.ctx.agents.get(SessionId(created.sessionId))! + agent.model.select({ provider: 'mock', model: 'mock' }) await expect(harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'route me' }], diff --git a/packages/acp/acp/tests/model-control.spec.ts b/packages/acp/acp/tests/model-control.spec.ts index 51db21271d..59a5f6a422 100644 --- a/packages/acp/acp/tests/model-control.spec.ts +++ b/packages/acp/acp/tests/model-control.spec.ts @@ -1,4 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { AgentModel, modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type ModelSelection } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { ReasoningEffortId, type LlmRuntime } from '@deepseek-ai/dsh-llm' import { AcpModelControl } from '../src/model-control.ts' @@ -30,21 +35,34 @@ function llmRuntime(overrides: Partial = {}): LlmRuntime { } as unknown as LlmRuntime } +async function createControl(runtime: LlmRuntime, initial?: ModelSelection) { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(SessionId('model-control')) + const options = initial ?? {} + const projection = await ctx.sessionProjections.register(modelSelectionProjectionDefinition) + const model = new AgentModel(() => projection.stateOf(session), session, options) + return { control: new AcpModelControl(runtime, model), session, model } +} + describe('ACP model configuration control', () => { it('represents an absent route and validates value types before mutation', async () => { - const control = new AcpModelControl(llmRuntime(), undefined) + const { control, session, model } = await createControl(llmRuntime()) - expect(control.snapshot()).toBeUndefined() + expect(model.current()).toBeUndefined() await expect(control.options()).resolves.toEqual([]) await expect(control.set('model', false)).rejects.toThrow(/requires a select value/) await expect(control.set('model', 'missing')).rejects.toThrow(/no model selection/) - control.selection.current = { provider: 'mock', model: 'mock' } - expect(control.selection.current).toEqual({ provider: 'mock', model: 'mock' }) + session.append('model/selection', { provider: 'mock', model: 'mock' }) + expect(model.current()).toEqual({ provider: 'mock', model: 'mock' }) }) it('synthesizes an unlisted current route and exposes reasoning descriptions', async () => { - const control = new AcpModelControl(llmRuntime({ listProviders: () => [] }), { + const { control, model: agentModel } = await createControl(llmRuntime({ listProviders: () => [] }), { provider: 'private', model: 'unlisted', }) @@ -64,17 +82,13 @@ describe('ACP model configuration control', () => { options: [{ name: 'Low', description: 'Less thought.' }, { name: 'High' }], }) - control.pinTurn(3, { provider: 'turn', model: 'pinned' }) - expect(control.selection.current).toEqual({ provider: 'turn', model: 'pinned' }) - control.releaseTurn(2) - expect(control.selection.current).toEqual({ provider: 'turn', model: 'pinned' }) - control.releaseTurn(3) - expect(control.selection.current).toEqual({ provider: 'private', model: 'unlisted' }) + expect(agentModel.current()).toEqual({ provider: 'private', model: 'unlisted' }) + }) it('keeps the selected route when its provider catalog is temporarily unavailable', async () => { const listModels = vi.fn(() => Promise.reject(new Error('catalog unavailable'))) - const control = new AcpModelControl(llmRuntime({ listModels }), { provider: 'mock', model: 'mock' }) + const { control } = await createControl(llmRuntime({ listModels }), { provider: 'mock', model: 'mock' }) const options = await control.options() @@ -86,12 +100,15 @@ describe('ACP model configuration control', () => { }) it('rejects an unadvertised reasoning effort and accepts a later valid change', async () => { - const control = new AcpModelControl(llmRuntime(), { provider: 'mock', model: 'mock' }) + const { control, session } = await createControl(llmRuntime(), { provider: 'mock', model: 'mock' }) await expect(control.set('reasoning_effort', 'extreme')).rejects.toThrow(/unknown reasoning effort/) const options = await control.set('reasoning_effort', 'low') expect(options.find(option => option.id === 'reasoning_effort')).toMatchObject({ currentValue: 'low' }) + expect(session.snapshotEvents().map(event => ({ type: event.type, data: event.data }))).toEqual([{ + type: 'model/selection', data: { provider: 'mock', model: 'mock', reasoningEffort: 'low' }, + }]) }) it('exposes and restores a provider-owned reasoning default', async () => { @@ -115,7 +132,7 @@ describe('ACP model configuration control', () => { }, }), }) - const control = new AcpModelControl(runtime, { provider: 'mock', model: 'mock' }) + const { control, model } = await createControl(runtime, { provider: 'mock', model: 'mock' }) const initial = await control.options() expect(initial.find(option => option.id === 'reasoning_effort')).toMatchObject({ @@ -126,6 +143,6 @@ describe('ACP model configuration control', () => { const restored = await control.set('reasoning_effort', '') expect(restored.find(option => option.id === 'reasoning_effort')).toMatchObject({ currentValue: '' }) - expect(control.selection.current).toEqual({ provider: 'mock', model: 'mock' }) + expect(model.current()).toEqual({ provider: 'mock', model: 'mock' }) }) }) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 1ef2bc94ce..23a412da93 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -1,7 +1,8 @@ +import { AcpSession } from '../src/session.ts' import { mountWorkingDirectoryFixture } from '../../../subagent/subagent/tests/working-directory-fixture.ts' import { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' import { setImmediate } from 'node:timers/promises' @@ -470,13 +471,16 @@ describe('ACP prompt lifecycle', () => { expect(harness.adapter.requests).toEqual([]) }) - it('honors cancellation in the admission-to-followup handoff gap', async () => { + it('cancels after image storage completes before admission receives the result', async () => { harness = await makeBridgeHarness({ imageCapable: true, script: [] }) const sessionId = await newSession(harness) const saveImages = harness.attachments!.saveImages.bind(harness.attachments!) + const cancel = vi.spyOn(AcpSession.prototype, 'cancel') + onTestFinished(() => { cancel.mockRestore() }) vi.spyOn(harness.attachments!, 'saveImages').mockImplementationOnce(async (inputs) => { const refs = await saveImages(inputs) - queueMicrotask(() => { void harness!.client.cancel({ sessionId }) }) + await harness!.client.cancel({ sessionId }) + await vi.waitFor(() => { expect(cancel).toHaveBeenCalledOnce() }) return refs }) diff --git a/packages/api/job-controller/tests/host-harness.ts b/packages/api/job-controller/tests/host-harness.ts index 4a8fca3bfe..78e6e2ba8c 100644 --- a/packages/api/job-controller/tests/host-harness.ts +++ b/packages/api/job-controller/tests/host-harness.ts @@ -1,7 +1,7 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobAppendOptions, JobHandle, JobOutcome } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -26,6 +26,7 @@ export async function registerAgent(ctx: Context, rawId: string): Promise { id: session.id, options: {}, session, + model: unsupportedModel(), inbox: unsupportedInbox(), status: 'idle', ctx, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index ff8ee89c2f..0bc8aff3f8 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -21,11 +21,11 @@ en: 41bdda1ac32df558 zh: aa5d0e325924066b /session-controller/session-media-references: - en: 266ab2b1e2fec542 - zh: b3450ae28a1e0baf + en: 49a3d0e8e92017d3 + zh: 91302aa3dd6e9378 /session-controller/configuration: - en: 5ae2b340c630897f - zh: 1ffbd61ecf302ec2 + en: c92590fbaf75e45e + zh: fcac67017bb0e3ab /session-controller/model-experience: en: 30b917bf822bb4df zh: b0b023b5b526c317 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index fbc0aeaa3b..b935dfe606 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -90,7 +90,7 @@ References keep local Session data, scoped Contexts, and history streams alive, GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel()` saves the first available account model after login when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. -A successful `selectModel` response acknowledges the Session-local selection without waiting for the default profile setting to save. Default saves run in the background in submission order; a failure logs a warning and leaves the Session selection intact. New Sessions read the last successfully saved default. +A successful `selectModel` response acknowledges the recorded Session selection without waiting for the default profile setting to save. The loop registers its projection; a change applies to the next step while a prepared step retains its captured choice. Default saves run in the background in submission order; a failure logs a warning and leaves the Session selection intact. A Session with no recorded selection or request reads the last successfully saved default through live `AgentOptions` accessors. An explicit choice is recorded even when it equals that default. Omitting reasoning effort clears the explicit effort and lets each request use the adapter default. ----- @@ -106,6 +106,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ----- +Blockquotes travel as ordinary prompt text without quote metadata. Local submission echoes and durable messages use the same text rendering rules; queue edits replace content while retaining prompt correlation. + ## Model Experience diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index d63735689f..57262c5267 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -90,7 +90,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel()` 在账号登录后、其他提供方均未配置 API key 时,将第一个可用账号模型保存为默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。 -`selectModel` 成功返回表示会话级模型选择已生效,不等待默认 profile 设置保存。默认设置在后台按提交顺序保存;保存失败会记录警告,并保留会话选择。新会话读取最近一次成功保存的默认值。 +`selectModel` 成功返回表示会话模型选择已记录,不等待默认 profile 设置保存。循环注册该投影;变更从下一个步骤开始生效,已准备的步骤保留捕获的选择。默认设置在后台按提交顺序保存;保存失败会记录警告,并保留会话选择。尚未记录选择或请求的会话通过 `AgentOptions` 的动态访问器读取最近一次成功保存的默认值。显式选择即使与默认值相同也会记录。省略推理强度会清除显式强度,让每次请求使用适配器默认值。 ----- @@ -106,6 +106,8 @@ GUI 模型选择要求确切提供方/模型对出现在可用目录中;不 ----- +Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地提交回显与持久消息使用同一文本渲染规则;队列编辑替换 content,同时保留 prompt 关联信息。 + ## 模型体验 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 4dee6de84a..4467fefc0f 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -1,24 +1,18 @@ -/** Agent activation, composition, and model-selection policy owned by API Session. */ +/** Agent activation, composition, and initial model route owned by API Session. */ import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' -import { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { - Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, -} from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' +import { installModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { brandString } from '@deepseek-ai/dsh-brand' -import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' -import type { ModelSelection, ModelSelectionProjectionState } from './types.ts' -import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' -import { modelSelectionProjection } from './model-selection-projection.ts' /** Cold Session identity absent from persistence. */ export class ApiSessionNotFound extends Error {} @@ -97,11 +91,6 @@ export type ApiSessionAgentResult = | { readonly agent: Agent } | { readonly error: ApiSessionAgentError } -type InstalledSelection = ModelSelectionRef & { - current: AgentModelSelection - consume(provider: string, model: string, reasoningEffort: string | undefined): boolean -} - /** * Test whether generic Session routing must leave an identity to subagent routing. * @param ctx - Host context carrying the Agent ownership registry. @@ -170,22 +159,18 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { - private modelSelectionProjectionHandle!: ProjectionHandle private readonly resumes = new Map>() private readonly claims = new Map>() - private readonly selections = new WeakMap() private readonly imageAdmissionChains = new WeakMap>() /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ constructor(private readonly ctx: Context) {} /** - * Acquire projection readers before exposing dependent operations. - * @returns when all required registrations are ready. + * Configure Session and Agent lookups for Typert. */ - async initialize(): Promise { + initialize(): void { const ctx = this.ctx - this.modelSelectionProjectionHandle = await ctx.sessionProjections.register(modelSelectionProjection) ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw found.error @@ -292,80 +277,6 @@ export class ApiSessionAgentController { return this.inOrder(named, async () => this.created(await this.claim(named, request))) } - /** - * Install or return the Session-local model selection used by prompt assembly. - * @param agent - live Agent that owns the selection. - * @returns the installed mutable selection reference. - */ - selectionFor(agent: Agent): InstalledSelection { - const installed = this.selections.get(agent) - if (installed !== undefined) return installed - const projectionState = this.modelSelectionProjectionHandle.stateOf(agent.session) - let picked = projectionState.pending === null - ? undefined - : agentModelSelection(projectionState.pending) - const defaultModel = this.ctx.agentDefaultModel - const selection: InstalledSelection = { - get current(): AgentModelSelection { - if (picked !== undefined) return picked - const loggedHeader = agent.session.requestHeader() - if (loggedHeader === undefined) return defaultModel.currentSelection() - const logged = loggedHeader.config - return { - provider: logged.provider, - model: logged.model, - // An effort the adapter defaulted is not a conversation choice: restoring - // it as one would make an unchanged default read as a request change. - ...(logged.reasoningEffort === undefined - || loggedHeader.adapterDefaults?.reasoningEffort === true - ? {} - : { reasoningEffort: logged.reasoningEffort }), - } - }, - set current(next: AgentModelSelection) { - picked = next - }, - consume(provider: string, model: string, reasoningEffort: string | undefined): boolean { - if (picked?.provider !== provider - || picked.model !== model - || picked.reasoningEffort !== reasoningEffort) return false - picked = undefined - return true - }, - assembled: undefined, - } - installModelSelection(agent.ctx, selection) - this.selections.set(agent, selection) - return selection - } - - /** - * Commit and cache one validated selection for the next prompt assembly. - * @param agent - live Agent that owns the selection. - * @param selection - validated selection to record and apply. - */ - selectForNextRequest(agent: Agent, selection: AgentModelSelection): void { - agent.session.append('model/selection', selection) - this.selectionFor(agent).current = selection - } - - /** - * Let a matching durable request header retire the execution cache. - * @param agent - live Agent whose request was recorded. - * @param provider - provider route used by the request. - * @param model - provider-owned model used by the request. - * @param reasoningEffort - adapter-owned effort used by the request. - * @returns whether the pending selection was consumed. - */ - consumeSelection( - agent: Agent, - provider: string, - model: string, - reasoningEffort: string | undefined, - ): boolean { - return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false - } - /** * Read the current Agent preset from the Session projection. * @param session - live Session whose projection state is available. @@ -395,13 +306,13 @@ export class ApiSessionAgentController { async composeAgent(presetId: string | undefined): Promise { const presets = this.ctx.get('agentPresets') if (presets === undefined) { - return { setup: (_agentCtx, agent) => { this.installSelection(agent) } } + return { setup: (agentCtx) => { installModelSelection(agentCtx) } } } const resolvedId = (await presets.resolve(presetId)).id return { agentPreset: resolvedId, - setup: async (agentCtx, agent) => { - this.installSelection(agent) + setup: async (agentCtx) => { + installModelSelection(agentCtx) await presets.mount(agentCtx, resolvedId) }, } @@ -586,12 +497,13 @@ export class ApiSessionAgentController { } private agentOptions(): AgentOptions { - const { provider, model } = this.ctx.agentDefaultModel.currentSelection() - return { provider, model } - } - - private installSelection(agent: Agent): void { - this.selectionFor(agent) + const defaults = this.ctx.agentDefaultModel + // A Session that records no selection or request follows default changes. + return { + get provider() { return defaults.currentSelection().provider }, + get model() { return defaults.currentSelection().model }, + get reasoningEffort() { return defaults.currentSelection().reasoningEffort }, + } } /** @@ -606,13 +518,3 @@ export class ApiSessionAgentController { return observation.projections.values.agentPreset ?? undefined } } - -function agentModelSelection(selection: ModelSelection): AgentModelSelection { - return { - provider: selection.provider, - model: selection.model, - ...(selection.reasoningEffort === undefined - ? {} - : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }), - } -} diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 8dd38573c8..59e32cd431 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -93,7 +93,7 @@ function latestCompletedPrefixBoundary(events: readonly SessionEvent[]): Session export class SessionCommandController { /** * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. - * @param agents - sole owner of create, resume, and Session-local model selection. + * @param agents - sole owner of create, resume, and Agent composition. * @param defaultCwd - project directory used when create names neither a Workspace nor a cwd. */ constructor( @@ -150,30 +150,24 @@ export class SessionCommandController { } /** - * Validate and install one Session-local model selection; save the default in the background. + * Validate and record one Session model selection; save the default in the background. * @param request - Session identity and requested model selection. - * @returns the normalized selection installed for the Session, without waiting for default persistence. + * @returns the normalized selection recorded for the Session, without waiting for default persistence. */ async selectModel(request: SessionSelectModelRequest): Promise { const agent = await this.resolveAgent(request.sessionId) return this.agents.serializeImageAdmission(agent, async () => { try { await this.requireModel(request) - const resolved = await this.ctx.llm.resolveCallConfig({ + const selected: AgentModelSelection = { provider: request.provider, model: request.model, ...(request.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(request.reasoningEffort) }), - }) - const selected: AgentModelSelection = { - provider: resolved.provider, - model: resolved.model, - ...(resolved.reasoningEffort === undefined - ? {} - : { reasoningEffort: resolved.reasoningEffort }), } - this.agents.selectForNextRequest(agent, selected) + await this.ctx.llm.resolveCallConfig(selected) + agent.model.select(selected) void this.ctx.agentDefaultModel.saveSelection(selected).catch((error: unknown) => { this.ctx.logger.warn( `session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`, @@ -323,14 +317,16 @@ export class SessionCommandController { const admit = async (): Promise => { try { if (hasImage) { - const current = this.agents.selectionFor(agent).current - const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model) - if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { - throw new RemoteError( - 'session/attachment-invalid', - `Model "${current.model}" does not support image input.`, - { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, - ) + const current = agent.model.current() + if (current !== undefined) { + const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model) + if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { + throw new RemoteError( + 'session/attachment-invalid', + `Model "${current.model}" does not support image input.`, + { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + ) + } } } const admission = resolvePromptFileReceipts( diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 5666d8dc08..5eeb7ae03d 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -169,7 +169,7 @@ export class SessionController extends TypertRemoteService { const ctx = this.ctx ctx.typert.resources.register('session', { field: 'sessionId' }) this.agents = new ApiSessionAgentController(ctx) - await this.agents.initialize() + this.agents.initialize() this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => { const result = await this.agents.resolveAgent(sessionId) @@ -213,15 +213,6 @@ export class SessionController extends TypertRemoteService { ctx.emit('api-session/error', agent.id, errorChain(error)) }) ctx.on('session/event', (session, event) => { - if (event.type === 'request/header') { - const agent = ctx.agents.get(session.id) - if (agent?.session === session) this.agents.consumeSelection( - agent, - event.data.header.config.provider, - event.data.header.config.model, - event.data.header.config.reasoningEffort, - ) - } if (event.type !== 'user/message' || event.data.source.kind !== 'user') return ctx.emit('api-session/activity', session.id, event.time) }) diff --git a/packages/api/session-controller/src/model-selection-projection.ts b/packages/api/session-controller/src/model-selection-projection.ts deleted file mode 100644 index 071cf5e685..0000000000 --- a/packages/api/session-controller/src/model-selection-projection.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** Durable model-selection intent and request-use projection. */ - -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import { z } from 'zod' -import type { - ModelSelection, - ModelSelectionProjection, - ModelSelectionProjectionState, -} from './types.ts' - -const modelSelectionSchema = z.object({ - provider: z.string().min(1), - model: z.string().min(1), - reasoningEffort: z.string().min(1).optional(), -}) as unknown as z.ZodType - -const modelSelectionProjectionStateSchema = z.object({ - lastUsed: modelSelectionSchema.nullable(), - pending: modelSelectionSchema.nullable(), -}) as z.ZodType - -const modelSelectionProjectionSchema = z.object({ - lastUsed: modelSelectionSchema.nullable(), - next: modelSelectionSchema.nullable(), -}) as z.ZodType - -/** - * Advance durable model-selection state by one Session event. - * @param state - selection state before the event. - * @param event - next committed Session event. - * @returns the original or advanced selection state. - */ -function applyModelSelectionProjection( - state: ModelSelectionProjectionState, - event: SessionEvent, -): ModelSelectionProjectionState { - if (event.type === 'model/selection') { - return sameSelection(state.pending, event.data) - ? state - : { lastUsed: state.lastUsed, pending: event.data } - } - if (event.type !== 'request/header') return state - const lastUsed: ModelSelection = { - provider: event.data.header.config.provider, - model: event.data.header.config.model, - ...(event.data.header.config.reasoningEffort === undefined - ? {} - : { reasoningEffort: String(event.data.header.config.reasoningEffort) }), - } - const pending = sameSelection(state.pending, lastUsed) ? null : state.pending - return sameSelection(state.lastUsed, lastUsed) && pending === state.pending - ? state - : { lastUsed, pending } -} - -/** Last-used and pending model selections shared by Session controller readers. */ -export const modelSelectionProjection = { - key: 'modelSelection', - stateSchema: modelSelectionProjectionStateSchema, - init: () => ({ lastUsed: null, pending: null }), - apply: applyModelSelectionProjection, - wire: { - viewSchema: modelSelectionProjectionSchema, - view: state => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }), - }, - stateVersion: 2, -} satisfies ProjectionDefinition<'modelSelection', ModelSelectionProjectionState> - -function sameSelection(left: ModelSelection | null, right: ModelSelection | null): boolean { - return left === right || (left !== null && right !== null - && left.provider === right.provider - && left.model === right.model - && left.reasoningEffort === right.reasoningEffort) -} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index f4d2daa637..f25a35d4ae 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,6 +1,7 @@ /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ -import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command/types' +import type {} from '@deepseek-ai/dsh-agent/types' +import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command/types' import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, } from '@deepseek-ai/dsh-attachment' @@ -23,18 +24,6 @@ declare module '@deepseek-ai/dsh-session-projection/types' { * it, so a list row would repeat one host-wide fact. */ imageLimits: SessionProjectionSummaryExcluded - /** Durable model selection already used and selected for the next request. */ - modelSelection: ModelSelectionProjection - } -} - -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * Complete validated model selection requested for subsequent prompt - * assembly. Log-only: it never enters derived model history. - */ - 'model/selection': ModelSelection } } @@ -101,22 +90,6 @@ export interface ModelSelection { readonly reasoningEffort?: string } -/** Host fold state for durable model selection. */ -export interface ModelSelectionProjectionState { - /** Selection consumed by the latest recorded model request. */ - readonly lastUsed: ModelSelection | null - /** Later user selection not yet consumed by a matching model request. */ - readonly pending: ModelSelection | null -} - -/** Client view of the durable model-selection fold. */ -export interface ModelSelectionProjection { - /** Selection consumed by the latest recorded model request. */ - readonly lastUsed: ModelSelection | null - /** Selection the next request should use, falling back to {@link lastUsed}. */ - readonly next: ModelSelection | null -} - /** One adapter-owned reasoning effort for an exact model route. */ export interface ModelReasoningEffort { readonly id: string diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 9dba50297c..76380d69fb 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -1,9 +1,9 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' @@ -44,7 +44,7 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl saveSelection: () => Promise.resolve(), } as never) const agents = new ApiSessionAgentController(ctx) - await agents.initialize() + agents.initialize() return { ctx, agents } } @@ -288,40 +288,6 @@ describe('ApiSession Agent lookup and recovery', () => { }) }) -describe('ApiSession model selection', () => { - it('reads a reasoning-free request and consumes only the exact pending selection', async () => { - const { ctx, agents } = await harness() - const logged = agent(ctx, header('logged-model')) - logged.session.append('request/header', { - header: { config: { provider: 'logged-provider', model: 'logged-model' } }, - reason: 'initial', - }) - expect(agents.selectionFor(logged).current).toEqual({ - provider: 'logged-provider', - model: 'logged-model', - }) - - const pending = agent(ctx, header('pending-model')) - const selection = agents.selectionFor(pending) - agents.selectForNextRequest(pending, { - provider: 'selected-provider', - model: 'selected-model', - reasoningEffort: 'high' as never, - }) - expect(selection.current).toMatchObject({ - provider: 'selected-provider', model: 'selected-model', reasoningEffort: 'high', - }) - expect(agents.consumeSelection(pending, 'other-provider', 'selected-model', 'high')).toBe(false) - expect(agents.consumeSelection(pending, 'selected-provider', 'other-model', 'high')).toBe(false) - expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'low')).toBe(false) - expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'high')).toBe(true) - expect(selection.current).toEqual({ provider: 'fixture', model: 'fixture-model' }) - - const untouched = agent(ctx, header('uninstalled-model')) - expect(agents.consumeSelection(untouched, 'fixture', 'fixture-model', undefined)).toBe(false) - }) -}) - describe('ApiSession creation', () => { it('rejects a resume that subagent routing adopts before publication', async () => { const child = await harness() diff --git a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts index d7ab911e57..f85750bafe 100644 --- a/packages/api/session-controller/tests/archived-session-gate.host.spec.ts +++ b/packages/api/session-controller/tests/archived-session-gate.host.spec.ts @@ -1,8 +1,8 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' -import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-workspace' @@ -31,6 +31,7 @@ async function liveAgent(ctx: Context, session: Session): Promise { id: session.id, options: {}, session, + model: unsupportedModel(), inbox: unsupportedInbox(), status: 'idle', ctx, @@ -49,7 +50,7 @@ async function liveAgent(ctx: Context, session: Session): Promise { /** Propose one model step for the Agent; the innermost decision enters. */ const proposed = (ctx: Context, agent: Agent) => ctx.waterfall( 'agent/pre-step', - { agent, messages: [], turn: 1, step: 1, signal: new AbortController().signal }, + { modelSelection: { provider: 'mock', model: 'mock' }, agent, messages: [], turn: 1, step: 1, signal: new AbortController().signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 246d512896..a23917b312 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -1,8 +1,9 @@ +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' @@ -16,7 +17,7 @@ import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/ import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' -import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import { unsupportedModel, createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' declare module '@deepseek-ai/dsh-llm' { @@ -34,11 +35,13 @@ async function commandHarness( inbox: Inbox steer: ReturnType cancel: ReturnType + resolveAgent: ReturnType }> { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(TypertRegistry) installSessionReadTestServices(ctx) await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const sessionId = SessionId('commands-session') @@ -84,32 +87,31 @@ async function commandHarness( const cancel = vi.fn() const agent = { id: session.id, + options: { provider: 'fixture', model: 'fixture-model' }, + whenIdle: vi.fn(() => Promise.resolve()), + runMaintenance: async () => { throw new Error('unexpected maintenance') }, + send: vi.fn(), + inject: vi.fn(), session, + model: unsupportedModel(), inbox, status: 'running', ctx, steer, followup: vi.fn(), cancel, - } as unknown as Agent + } satisfies Agent await ctx.agents.register(agent) ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), saveSelection: () => Promise.resolve(), } as never) - const selection: ModelSelectionRef = { - current: { provider: 'fixture', model: 'fixture-model' }, - assembled: undefined, - } - const agents = { - resolveAgent: (id: SessionId) => Promise.resolve(id === agent.id - ? { agent } - : { error: new RemoteError('session/not-found', 'missing', { sessionId: id }) }), - selectionFor: () => selection, - serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), - composeAgent: () => Promise.resolve({ setup: () => {} }), - } as unknown as ApiSessionAgentController + const agents = new ApiSessionAgentController(ctx) + const resolveAgent = vi.spyOn(agents, 'resolveAgent').mockImplementation(id => Promise.resolve(id === agent.id + ? { agent } + : { error: new RemoteError('session/not-found', 'missing', { sessionId: id }) })) + vi.spyOn(agents, 'composeAgent').mockResolvedValue({ setup: () => {} }) return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), @@ -117,6 +119,7 @@ async function commandHarness( inbox, steer, cancel, + resolveAgent, } } @@ -125,6 +128,19 @@ async function expectFailure(operation: Promise, code: string): Promise } describe('Session queue commands', () => { + it('edits a blockquote into ordinary text without changing prompt correlation', async () => { + const { ctx, controller, agent, inbox } = await commandHarness() + try { + const message = createUserMessage({ content: [{ type: 'text', text: '> selected' }], + source: { kind: 'user', rpcId: 'quote-edit' as import('../src/types.ts').SessionRequestId } }) + inbox.append('next-turn', message) + await controller.updateQueue({ sessionId: agent.id, itemId: message.id, + action: { kind: 'edit', content: [{ type: 'text', text: 'plain replacement' }] } }) + expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'plain replacement' }]) + expect(inbox.nextTurn[0]?.source).toEqual(message.source) + } finally { await ctx.fiber.dispose() } + }) + it('preserves the cold Agent resolver rejection', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 48ba896f01..305bace577 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -1,8 +1,9 @@ +import { AgentModel, modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Context, FiberState } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' -import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, @@ -15,7 +16,7 @@ import { createScope } from '@deepseek-ai/dsh-scope' import FileUploads from '@deepseek-ai/dsh-client-file-upload' import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' import { describe, expect, it, onTestFinished, vi } from 'vitest' -import type { ApiSessionAgentController } from '../src/agent.ts' +import { ApiSessionAgentController } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' import type { SessionRequestId } from '../src/types.ts' @@ -44,17 +45,25 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ }) const inbox = createInboxStub() const followup = vi.fn() + const options = { provider: 'fixture', model: 'fixture-model' } + const projection = await ctx.sessionProjections.register(modelSelectionProjectionDefinition) const agent = { id: session.id, + options, + whenIdle: vi.fn(() => Promise.resolve()), + runMaintenance: async () => { throw new Error('unexpected maintenance') }, + send: vi.fn(), + inject: vi.fn(), session, + model: new AgentModel(() => projection.stateOf(session), session, options), inbox, status: 'idle', - ctx: undefined, + ctx, steer: vi.fn(), followup, cancel: vi.fn(), - } as unknown as Agent - ;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx + } satisfies Agent + agent.ctx = createScope(ctx, agent).ctx const disposeAgent = await ctx.agents.register(agent) const saveFile = vi.fn((input: SaveFileAttachment): Promise => Promise.resolve({ attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`), @@ -90,15 +99,8 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ listProviders: () => [{ id: 'fixture', name: 'Fixture' }], resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), } as never) - const selection: ModelSelectionRef = { - current: { provider: 'fixture', model: 'fixture-model' }, - assembled: undefined, - } - const agents = { - resolveAgent: () => Promise.resolve({ agent }), - selectionFor: () => selection, - serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), - } as unknown as ApiSessionAgentController + const agents = new ApiSessionAgentController(ctx) + vi.spyOn(agents, 'resolveAgent').mockResolvedValue({ agent }) const uploads = new FileUploads(ctx) if (uploadRoute === undefined) throw new Error('file upload route was not registered') return { @@ -227,6 +229,28 @@ describe('Session file uploads', () => { expect(saveImages).not.toHaveBeenCalled() }) + it('admits an image before the Agent has a model selection', async () => { + const { ctx, controller, agent, followup, saveImages } = await uploadHarness() + delete agent.options.provider + delete agent.options.model + expect(agent.model.current()).toBeUndefined() + const resolveModel = vi.spyOn(ctx.llm, 'resolveModelInfo') + const image: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + mediaType: 'image/png', bytes: 3, width: 1, height: 1, + } + saveImages.mockResolvedValueOnce([image]) + + await expect(controller.prompt(promptRequest([ + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + ]), USER_SOURCE)).resolves.toEqual({ accepted: true }) + + expect(resolveModel).not.toHaveBeenCalled() + expect(followup).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + content: [{ type: 'image', attachment: image }], + })) + }) + it('publishes no receipt when its exact Agent is disposed during storage', async () => { const { uploads, agent, saveFile, disposeAgent } = await uploadHarness() const saved = Promise.withResolvers() diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index cbee7aa031..789caf78d5 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -1,12 +1,13 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import SessionController from '../src/index.ts' import type { ApiSessionAgentController } from '../src/agent.ts' import { createSessionTestController, testSessionPersistence } from './test-remote.ts' @@ -23,9 +24,9 @@ describe('SessionController facade', () => { it('owns Host service methods and publishes Agent lifecycle projections', async () => { const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentRegistry) + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) const sessionId = SessionId('controller-session') const header: SessionHeader = { version: SESSION_FORMAT_VERSION, @@ -88,10 +89,6 @@ describe('SessionController facade', () => { 'resolveAgent', ).mockResolvedValueOnce({ error: activationError }) await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError) - const consumeSelection = vi.spyOn( - (controller as unknown as { agents: ApiSessionAgentController }).agents, - 'consumeSelection', - ) await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent }) await expect(controller.inspect(sessionId)).resolves.toEqual({ @@ -117,9 +114,9 @@ describe('SessionController facade', () => { header: { config: { provider: 'fixture', model: 'fixture-model' } }, reason: 'initial', }) - expect(consumeSelection).toHaveBeenCalledWith( - agent, 'fixture', 'fixture-model', undefined, - ) + expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({ + lastUsed: { provider: 'fixture', model: 'fixture-model' }, next: { provider: 'fixture', model: 'fixture-model' }, + }) const unowned = ctx.sessions.create(SessionId('controller-unowned'), { meta: { cwd: '/workspace' }, }) @@ -127,7 +124,9 @@ describe('SessionController facade', () => { header: { config: { provider: 'fixture', model: 'other-model' } }, reason: 'initial', }) - expect(consumeSelection).toHaveBeenCalledTimes(1) + expect(ctx.sessionProjections.snapshot(unowned).values.modelSelection).toEqual({ + lastUsed: { provider: 'fixture', model: 'other-model' }, next: { provider: 'fixture', model: 'other-model' }, + }) const abort = new AbortController() const iterator = controller.follow({ diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 1544d49c4c..1bc9297103 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -3,13 +3,14 @@ * metadata-only listing, Agent-free history reads, subagent ownership * isolation, and prompt failure mapping. */ +import { AgentModel, modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -17,7 +18,7 @@ import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { createInboxStub, mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' -import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import AttachmentStore from '@deepseek-ai/dsh-attachment' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { @@ -726,9 +727,14 @@ describe('subagent ownership fence', () => { inheritedEventCount: SessionLogOffset(1), }) const followup = vi.fn() - const agent = { - id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, - } as unknown as Agent + const agent: Agent = { + id: session.id, session, model: await modelFor(ctx, session), + options: { provider: 'p', model: 'm' }, + inbox: inboxFor(), status: 'idle', ctx, followup, + cancel: vi.fn(), whenIdle: () => Promise.resolve(), + runMaintenance: async () => { throw new Error('unexpected maintenance') }, + send: vi.fn(), inject: vi.fn(), steer: vi.fn(), + } await ctx.agents.register(agent) const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -748,9 +754,14 @@ describe('subagent ownership fence', () => { await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) const followup = vi.fn() - const agent = { - id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, - } as unknown as Agent + const agent: Agent = { + id: session.id, session, model: await modelFor(ctx, session), + options: { provider: 'p', model: 'm' }, + inbox: inboxFor(), status: 'idle', ctx, followup, + cancel: vi.fn(), whenIdle: () => Promise.resolve(), + runMaintenance: async () => { throw new Error('unexpected maintenance') }, + send: vi.fn(), inject: vi.fn(), steer: vi.fn(), + } await ctx.agents.register(agent) const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), @@ -861,6 +872,11 @@ describe('degenerate composition (no persistence, no factory)', () => { }) }) +async function modelFor(ctx: Context, session: Session): Promise { + const projection = await ctx.sessionProjections.register(modelSelectionProjectionDefinition) + return new AgentModel(() => projection.stateOf(session), session, { provider: 'p', model: 'm' }) +} + describe('sessions.prompt synchronous rejection', () => { it('rejects content without non-whitespace text or an attachment before delivery or Session events', async () => { const ctx = new Context() @@ -868,17 +884,25 @@ describe('sessions.prompt synchronous rejection', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-empty-prompt')) + const options = { provider: 'p', model: 'm' } const followup = vi.fn() const steer = vi.fn() await ctx.agents.register({ id: session.id, + options, + whenIdle: vi.fn(() => Promise.resolve()), + runMaintenance: async () => { throw new Error('unexpected maintenance') }, + send: vi.fn(), + inject: vi.fn(), + cancel: vi.fn(), session, + model: await modelFor(ctx, session), inbox: inboxFor(), status: 'idle', ctx, followup, steer, - } as unknown as Agent) + } satisfies Agent) const savedImage = { attachmentId: 'accepted-image', mediaType: 'image/png' as const, @@ -970,12 +994,17 @@ describe('sessions.prompt synchronous rejection', () => { await ctx.agents.register({ id: session.id, session, + model: await modelFor(ctx, session), inbox: inboxFor(), status: 'idle', ctx, + options: { provider: 'p', model: 'm' }, + cancel: vi.fn(), whenIdle: () => Promise.resolve(), + runMaintenance: async () => { throw new Error('unexpected maintenance') }, + send: vi.fn(), inject: vi.fn(), followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, - } as unknown as Agent) + }) const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 94a05234be..5b00f060f1 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -1,15 +1,14 @@ /** Session Controller fork boundaries, lineage, and inherited model routing. */ - -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' + +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import SessionStore, { TOOL_OUTCOME_UNKNOWN, SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' @@ -637,8 +636,12 @@ describe('sessions.fork', () => { }) it('inherits model selection through the completed turn and excludes later changes', async () => { - const ctx = await composed() - const source = await liveAgent(ctx, 'session-routed', 0) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + const harness = await mountAgentLoopTestHarness(ctx) + ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + const { session: source } = await harness.create(sid('session-routed')) source.append('turn/start', { turn: 1 }) source.append('request/header', { header: { @@ -667,19 +670,8 @@ describe('sessions.fork', () => { const child = ctx.agents.get(response.value.sessionId) if (child === undefined) throw new Error('fork did not publish the child agent') expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(inherited) - const assembly = await child.ctx.systemPrompt.assemble() - expect(assembly.variables).toMatchObject({ - provider: 'inherited-provider', - model: 'inherited-model', + expect(child.model.current()).toEqual({ + provider: 'inherited-provider', model: 'inherited-model', reasoningEffort: 'high', }) - const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' } - await expect(agentEvents(child.ctx, child).waterfall( - 'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback), - )).resolves.toMatchObject({ - provider: 'inherited-provider', - model: 'inherited-model', - reasoningEffort: 'high', - }) - await ctx.fiber.dispose() }) }) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index c5619d59aa..cda4c14e6a 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -4,23 +4,24 @@ * catalog injection, unavailable-model refusals, and the prompt-assembly * boundary for a running selection change. */ +import { AgentModel, modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it, vi, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import AttachmentStore from '@deepseek-ai/dsh-attachment' import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { - GenerateOptions, LlmCallConfig, LlmCallConfigAdapterDefaults, LlmModelInfo, + GenerateOptions, LlmCallConfigAdapterDefaults, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk, UserMessage, } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' -import { ApiSessionAgentController } from '../src/agent.ts' import { buildModelCatalog, hasProviderApiKey } from '../src/catalog.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' @@ -126,13 +127,28 @@ async function harness(logged?: { reason: 'initial', }) } + const projection = await ctx.sessionProjections.register(modelSelectionProjectionDefinition) + const options = { + get provider() { return ctx.agentDefaultModel.currentSelection().provider }, + get model() { return ctx.agentDefaultModel.currentSelection().model }, + get reasoningEffort() { return ctx.agentDefaultModel.currentSelection().reasoningEffort }, + } const agent = { id: session.id, + options, + model: new AgentModel(() => projection.stateOf(session), session, options), session, status: 'running', ctx, - inbox: { nextTurn: [], nextStep: [] }, - } as unknown as Agent + inbox: createInboxStub(), + whenIdle: vi.fn(() => Promise.resolve()), + runMaintenance: async () => { throw new Error('unexpected maintenance') }, + send: vi.fn(), + inject: vi.fn(), + cancel: vi.fn(), + followup: vi.fn(), + steer: vi.fn(), + } satisfies Agent await ctx.agents.register(agent) return { ctx, agent, sessionId: session.id } } @@ -488,11 +504,9 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('rejects unlisted models and switches available models only after the next assembly', async () => { + it('rejects unlisted models and records validated selections for the next step', async () => { const { ctx, agent, sessionId } = await harness() const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) - const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } - const signal = new AbortController().signal expect(currentSelection(ctx, sessionId)) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) @@ -508,18 +522,9 @@ describe('Web session model selection', () => { model: 'deepseek-reasoner', reasoningEffort: 'max', }) - await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), - )).resolves.toEqual(seed) - - expect((await ctx.systemPrompt.assemble()).variables) - .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) - await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed), - )).resolves.toMatchObject({ - provider: 'deepseek-official', - model: 'deepseek-reasoner', - reasoningEffort: 'max', + expect(agent.model.current()).toEqual(selected.selected) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ + type: 'model/selection', data: selected.selected, }) const unsupported = await remote.selectModel(request({ @@ -566,23 +571,43 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('reads the Agent default live for a session whose log names no selection', async () => { - const { ctx, sessionId } = await harness() + it('follows default changes until an explicit selection through the Web create entrypoint', async () => { + const { ctx, agent: template } = await harness() + onTestFinished(() => ctx.fiber.dispose()) let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - await createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => stored, + saveDefaultModelSelection: (selection) => { stored = selection }, cwd: '/tmp', }) - - expect(currentSelection(ctx, sessionId)) - .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) - // The default moving after the session exists still reaches it: New - // Session reuses a blank session rather than minting another, so a seed - // captured at creation would show the superseded model there. + ctx.agents.setFactory({ + async createAgent(ownerCtx, options) { + const session = ctx.sessions.create(options.sessionId) + const agentOptions = options.agentOptions ?? {} + const projection = await ownerCtx.sessionProjections.register(modelSelectionProjectionDefinition) + const model = new AgentModel(() => projection.stateOf(session), session, agentOptions) + const agent: Agent = { ...template, id: session.id, session, ctx: ownerCtx, options: agentOptions, model } + await options.setup?.(ownerCtx, agent) + await ctx.agents.register(agent) + return { agent, dispose: () => Promise.resolve() } + }, + resume: () => Promise.reject(new Error('unexpected resume')), + }) + const first = expectValue(await remote.create({ cwd: '/tmp' })) + const second = expectValue(await remote.create({ cwd: '/tmp' })) + const agent = ctx.agents.get(first.sessionId)! + expect(agent.model.current()).toEqual(stored) stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' } - expect(currentSelection(ctx, sessionId)) - .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) - await ctx.fiber.dispose() + expect(agent.model.current()).toEqual(stored) + const selection = { ...stored, reasoningEffort: ReasoningEffortId('high') } + // The explicit selection is identical to the live fallback, including effort. + stored = selection + expectValue(await remote.selectModel({ sessionId: first.sessionId, ...selection })) + expect(agent.session.snapshotEvents().filter(event => event.type === 'model/selection')) + .toMatchObject([{ data: selection }]) + expectValue(await remote.selectModel({ sessionId: second.sessionId, provider: 'deepseek-official', model: 'deepseek-chat' })) + expect(agent.model.current()).toEqual(selection) + expect(currentSelection(ctx, first.sessionId)).toEqual(selection) }) it('keeps a session on its logged selection when the Agent default differs', async () => { @@ -614,9 +639,7 @@ describe('Web session model selection', () => { cwd: '/tmp', }) - const controller = new ApiSessionAgentController(ctx) - await controller.initialize() - expect(controller.selectionFor(agent).current) + expect(agent.model.current()) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) await ctx.fiber.dispose() }) @@ -683,9 +706,10 @@ describe('Web session model selection', () => { const stillAccepted = expectValue(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'deepseek-chat', }))) - expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) + expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) expect(currentSelection(ctx, sessionId)) - .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) + expect(saved[1]).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) await expect.poll(() => warn.mock.calls).toContainEqual([ 'session-controller: model selection changed for the Session but the default was not saved: Error: read-only document', ]) @@ -770,6 +794,33 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('admits Markdown blockquote text without source metadata', async () => { + const { ctx, agent, sessionId } = await harness() + const followup = vi.fn() + Object.assign(agent, { followup }) + const remote = await createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + expectValue(await remote.prompt(promptRequest({ + sessionId, + mode: 'queue', + content: [ + { type: 'text', text: 'Question:\n\n' }, + { type: 'text', text: '> quoted source' }, + ], + }))) + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.content).toEqual([ + { type: 'text', text: 'Question:\n\n' }, + { type: 'text', text: '> quoted source' }, + ]) + expect(message.source.kind).toBe('user') + expect(message.source).not.toHaveProperty('quotes') + + await ctx.fiber.dispose() + }) + it('serves a session and its catalog when the stored default names a route that is gone', async () => { const { ctx, sessionId } = await harness() await createSessionTestRemote(ctx, { diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 8f90ca7a6a..8a5fa75166 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -18,7 +18,6 @@ "src/history.ts", "src/list.ts", "src/media-references.ts", - "src/model-selection-projection.ts", "src/archived-session-gate.ts", "src/skill-catalog.ts" ], diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 4c46daefd0..1b403edbb2 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -24,8 +24,8 @@ en: ca8217837ee9c698 zh: fa20daf27d4e09ef /deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity: - en: dbaa2fac63910a4c - zh: 4fd5c17815ca444b + en: 0ef0c533ef9aebea + zh: fcb267b61bd23437 /deepseek-ai-dsh-headless/use-this-package/supervising-implementation-tasks: en: b10dee3026e8d999 zh: 63eda004e228a48a diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index b96232b7d5..be01f57c86 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -74,6 +74,8 @@ The restored store uses [saved credentials before environment fallbacks](../../c Every invocation defaults to a fresh `session-` identity, which `--json` reports in its opening `session` event. Pass `--session-id ` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. The working directory is resolved through the mounted filesystem provider (`fs.resolve('.')` and `fs.processPath()`), or the process cwd when no filesystem service is mounted; new Sessions record that directory. Adoption compares the recorded cwd with the same provider-resolved directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs. +On adoption, the runner records the current deployment route before submitting the task. An omitted effort preserves an explicit saved effort only for the same route. A step already prepared in this process keeps its captured route; subsequent steps read the shared Session selection. + ### Supervising implementation tasks Use one prepared Git worktree per implementation task. Start the process with that worktree as its working directory. Supply a focused task on stdin and capture stdout as NDJSON; diagnostics remain on stderr. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index d52e539c49..5f462cf147 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -74,6 +74,8 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增 每次调用默认使用全新的 `session-` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id ` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。工作目录通过已挂载的文件系统提供方解析(`fs.resolve('.')` 与 `fs.processPath()`);未挂载文件系统服务时使用进程 cwd,新 Session 会记录该目录。沿用会将已记录 cwd 与同一提供方解析出的目录比较,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +接管会话时,运行器会在提交任务前记录当前部署路由。未声明推理强度时,仅在路由相同的情况下保留已保存的显式强度。本进程中已准备的步骤保留捕获的路由,后续步骤读取共享的 Session 选择。 + ### 监督实现任务 每个实现任务使用一个已准备好的 Git worktree。将该 worktree 设为进程工作目录,通过 stdin 提供范围明确的任务,并将 stdout 保存为 NDJSON;诊断仍写入 stderr。 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index a32aaa3ab0..6ad84ed4f6 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -14,9 +14,9 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' -import { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' +import { installModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { APPROVAL_POLICIES, setApprovalPolicy, type ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' @@ -259,8 +259,8 @@ async function resolveAgent( ctx: Context, agents: Context['agents'], sessionId: SessionId, - agentOptions: { provider: string; model: string }, - setup: (agentCtx: Context) => void, + agentOptions: AgentOptions, + setup: (agentCtx: Context, agent: Agent) => void | Promise, cwd: string, ): Promise { // Resuming promises the caller a log a later process can continue. Without a @@ -364,14 +364,24 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise } const selection = defaultModel.currentSelection() - const agentOptions = { provider: selection.provider, model: selection.model } + const agentOptions: AgentOptions = { ...selection } // This bundle composes no preset roster, so the model-facing rows sit in the // host plane and the agent reads them from the global layer. A deployment // that DOES configure one has to join it here first // (@deepseek-ai/dsh-agent-preset-registry README, "Composing a child agent"). - const setup = (agentCtx: Context): void => { - const selected: ModelSelectionRef = { current: selection, assembled: undefined } - installModelSelection(agentCtx, selected) + const setup = (agentCtx: Context, agent: Agent): void => { + // A resumed Session continues with this run's configured model. + if (config.sessionId !== undefined) { + const previous = agent.model.current() + const configured = { ...selection } + if (configured.reasoningEffort === undefined && previous !== undefined + && previous.provider === configured.provider && previous.model === configured.model + && previous.reasoningEffort !== undefined) { + configured.reasoningEffort = previous.reasoningEffort + } + agent.model.select(configured) + } + installModelSelection(agentCtx) } const sessionId = brandString(config.sessionId ?? `session-${randomUUID()}`) const fs = ctx.get('fs') diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 68720d75f4..54067fe50c 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -13,13 +13,14 @@ import type { ResumeAgentOptions, } from '@deepseek-ai/dsh-agent' import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model' -import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAttemptId, ReasoningEffortId, ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import ApprovalService, { setApprovalPolicy, type ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import { AgentModel, modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent' import { createInboxStub, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { apply, Config } from '../src/index.ts' import SubagentRuntime from '@deepseek-ai/dsh-subagent' @@ -164,9 +165,12 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ const inbox = createInboxStub() let idle = Promise.resolve() let running = false + const agentOptions = createOptions.agentOptions ?? {} + const projection = await ownerCtx.sessionProjections.register(modelSelectionProjectionDefinition) const agent: Agent = { id: session.id, - options: createOptions.agentOptions ?? {}, + options: agentOptions, + model: new AgentModel(() => projection.stateOf(session), session, agentOptions), session, inbox, get status() { return running ? 'running' : 'idle' }, @@ -871,6 +875,32 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it.each(['test-model', 'other-model'])('restores explicit effort only for the declared route (%s)', async (savedModel) => { + const test = await bench({ + afterPrompt(session, message, agent) { + expect(agent.model.current()).toEqual({ + provider: 'test-provider', model: 'test-model', + ...savedModel === 'test-model' ? { reasoningEffort: 'high' } : {}, + }) + appendTurn(session, 1, message, 'resumed answer', true) + }, + }, { + sessionId: 'session-effort', + observe: () => Promise.resolve({ + header: { cwd: process.cwd(), origin: 'user' }, + events: [], + [Symbol.dispose]() {}, + }), + }) + try { + const session = test.ctx.sessions.create(brandString('session-effort'), { meta: { cwd: process.cwd() } }) + session.append('model/selection', { provider: 'test-provider', model: savedModel, reasoningEffort: ReasoningEffortId('high') }) + expect(await test.run()).toMatchObject({ code: 0, out: 'resumed answer\n', err: '' }) + } finally { + await test.ctx.fiber.dispose() + } + }) + it('rejects a persisted Session recorded in another working directory', async () => { const test = await bench({ afterPrompt: () => {} }, { sessionId: 'session-exact', diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index 10b1fa0b5a..afb1e0019e 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -126,6 +126,7 @@ - id: agent name: '@deepseek-ai/dsh-agent' + - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 7f9dd10f40..1b44f09b93 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -329,6 +329,9 @@ - id: ui-approval name: '@deepseek-ai/dsh-client-ui-approval' + - id: message-quotes + name: '@deepseek-ai/dsh-message-quotes' + - id: ui-chat name: '@deepseek-ai/dsh-client-ui-chat' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 487009c170..a6aeab97d7 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-client-ui-approval": "workspace:*", "@deepseek-ai/dsh-client-ui-attachment": "workspace:*", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:*", + "@deepseek-ai/dsh-message-quotes": "workspace:*", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", "@deepseek-ai/dsh-client-ui-commands": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 2e2736659e..2b410ece29 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7fedfbcbc3ad8cea zh: e29ba5230e0b9ecb /deepseek-ai-dsh-client-locale/use-this-package/choosing-a-language: - en: 8527d77583824eff - zh: 2ac53ced0a32ba19 + en: 2b82458e185ba321 + zh: e55a7e709a3f0158 /deepseek-ai-dsh-client-locale/use-this-package/registering-a-dictionary: en: 9b2fcfa8cae8dc4b zh: 293012a4d364ef59 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 8c52703748..3add5d256c 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -29,6 +29,8 @@ Use it wherever the web GUI needs a language switch or translated copy: the ship ### Choosing a language +The Language row uses a smaller label gutter in narrow browser settings; Windows and macOS desktop windows retain the standard spacing. + Open Settings → General and select a registered language. The active locale is applied immediately: the UI copy switches, `` points at the external id or built-in document tag, and the choice is written to the durable settings section. A browser without an explicit Host preference selects the first registered language that matches `navigator` by full tag and then primary subtag, falling back to English. A stored external locale waits for its definition to register instead of becoming active while unavailable. Native shells may provide `__DSH_LOCALE__` with an asynchronous `read()` and an `onChange(locale)` callback. Initialization supplies the current Host preference and ordered OS languages before the Client mounts. Automatic selection stays provisional; only Settings selections write `locale.preference`. A fresh read on each page load prevents a stale preload preference after reload. Ordinary browsers keep navigator-based detection and their existing settings-scope policy. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 0527984591..bdbde381b3 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" ### 选择语言 +窄屏浏览器设置中,语言行缩小标签与控件的间距;Windows 和 macOS 桌面窗口保留标准间距。 + 打开“设置 → 常规”并选择一种已注册语言。生效中的 locale 会立即应用:UI 文案切换、`` 指向外部 id 或内置语言的文档标签,选择写入持久设置分区。没有显式 Host 偏好的浏览器会按完整标签、再按主语言子标签选择 `navigator` 请求的第一个已注册语言,无法匹配时回退到英文。已存储的外部 locale 会等待其定义注册,不会在不可用时生效。 原生壳可以提供包含异步 `read()` 和 `onChange(locale)` 回调的 `__DSH_LOCALE__`。初始化在 Client 挂载前提供当前 Host 偏好和有序的系统语言列表。自动选择保持临时状态;只有设置中的选择会写入 `locale.preference`。每次加载页面都重新读取,避免重载后沿用过期的 preload 偏好。普通浏览器继续使用 navigator 检测和原有的设置作用域策略。 diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css index 3d78e59775..794ec53652 100644 --- a/packages/client/locale/src/client/LanguageRow.module.css +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -18,6 +18,14 @@ padding-right: 48px; } +/* Narrow settings panel: the 48px gutter separates the label from its control + across a wide row and only squeezes the label here. */ +@media (max-width: 767px) { + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + padding-right: 16px; + } +} + .title { font-size: 14px; font-weight: 400; diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index abaab0beb5..b73a302dba 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -24,8 +24,8 @@ en: 41dcd70e14ebe9cc zh: ededead572b59037 /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: bb20da372b144302 - zh: 75a8c5ab7ec8d665 + en: 059eef868161efe4 + zh: 52ecc8e682d4c630 /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: 382821ca3ffa60dd zh: 9840ffa07c13082a @@ -36,8 +36,8 @@ en: a14c74ed7de0ef9c zh: d5e618b2ef809150 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 284737900cb75668 - zh: 12e0844082ff79ce + en: 009ecb4fecd4ee9e + zh: ec2714337a1102c5 /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index ed77f4074f..98b1aa5ec9 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -72,6 +72,8 @@ The composer statistics are two `conversation.composer.dock` list entries: `acti Explicit selections update every consumer immediately; accepted Host settings reconcile the live value, so a refused or pending write keeps the selected mode until the Host answers. +In narrow browser settings, preference labels use a smaller gutter before their controls. Windows and macOS desktop windows retain the standard spacing. + Preference menus restore focus to their trigger without scrolling before publishing a new selection. @@ -172,6 +174,10 @@ Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the f ----- +The Chat view detects selection and context-menu gestures on business nodes marked with `data-text-interaction-source`. Both selection endpoints must resolve to the same source container. Displayed user content and settled Assistant text or reasoning participate; tools, injected context, streaming or interrupted Assistant text, and Markdown controls do not. + +`ctx.uiConversation.interactions` dispatches eligible gestures to plugin registrations and supplies their component to the Chat view. The action surface is absolutely positioned inside the existing Chat column, above the composer seat and its dock. Its wrapper preserves the component's outer stroke and shadow; components own internal scrolling. Scrolling the selection outside that visible region temporarily hides the surface; returning restores it. Chat owns viewport visibility, placement, outside pointer presses, Escape, new selections, source invalidation, and disposal; explicit dismissal preserves browser selection and layout changes do not reopen the surface. [Message quotes](../../session/message-quotes/README.md) owns quotation eligibility and insertion. + ## Model Experience diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index ea539ee583..88fa3d4e5f 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -72,6 +72,8 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 明确选择会立即更新所有使用方;Host 接受的设置会校正实时值,因此写入被拒绝或尚未应答时保持所选模式,直到 Host 应答。 +窄屏浏览器设置中,偏好标签与控件的间距缩小;Windows 和 macOS 桌面窗口保留标准间距。 + 偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。 @@ -172,6 +174,10 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 ----- +Chat view 在标记了 `data-text-interaction-source` 的业务节点上检测选区和右键手势。选区两端必须定位到同一个来源容器。已展示的用户内容和已完成的 Assistant 文本或 reasoning 参与交互;Tool、注入上下文、流式或中断的 Assistant 文本以及 Markdown 控件不参与。 + +`ctx.uiConversation.interactions` 将有效手势分发给插件注册项,并把对应组件交给 Chat view。操作展示位置在已有 Chat 列内绝对定位,限制在 composer seat 及其 dock 上方。外层容器保留组件的外描边与阴影,内部滚动由组件负责。选区滚出该可见范围时暂时隐藏操作,滚回后恢复。Chat 负责视口可见性、定位、外部指针按下、Escape、新选区、来源失效与卸载清理;主动关闭保留浏览器选区,布局变化不会重新打开操作。[Message quotes](../../session/message-quotes/README.zh.md) 负责引用的接管条件和插入。 + ## 模型体验 diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 6432e3e09e..e61fe3d30c 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -205,11 +205,10 @@ export function apply(ctx: Context): void { }, TranscriptViewRow)) const connectionState = (ctx.get('connection') as ConnectionHandle).state - const nodeSources = (binding: SessionBinding): Pick => { + const nodeSources = (binding: SessionBinding): Pick => { const chat = chatSource(binding) const conversation = ctx.uiConversation.binding(binding) return { - hooks: { presentation }, keyedHooks: { chatNode: key => chat.getSnapshot().nodes.source(key), chatNodeBottom: key => chat.getSnapshot().nodes.bottomSource(key), @@ -227,8 +226,7 @@ export function apply(ctx: Context): void { inject: (sessionId: SessionId): ChatFlowDataInjected => { const binding = ctx.sessions.binding(sessionId) if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) - const sources = nodeSources(binding) - return { ...sources, hooks: { ...sources.hooks, connectionState } } + return { ...nodeSources(binding), hooks: { presentation, connectionState } } }, }, ChatFlow)) @@ -241,6 +239,7 @@ export function apply(ctx: Context): void { locale: NS, children: { 'conversation.chat.flow': { kind: 'single', scope: 'session', inject: CHAT_FLOW_INJECT }, + 'conversation.chat.text-interaction': { kind: 'keyed', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId): ChatViewInjected => { @@ -250,6 +249,8 @@ export function apply(ctx: Context): void { const chat = chatSource(binding) return { ...nodeSources(binding), + hooks: { presentation, interactionIds: ctx.uiConversation.interactions }, + dispatchInteraction: gesture => ctx.uiConversation.interactions.dispatch(sessionId, gesture), fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId), // Files open in the right Sidebar, not in a desktop application: the // content stays in the product, beside the conversation that produced diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 22ae5bac70..3d946f8c91 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -36,6 +36,8 @@ export interface AssistantMarkdownProps extends useDisclosure: UseDisclosure blocks: readonly AssistantBlock[] streaming: boolean + /** Only finalized durable Assistant messages supply selection sources. */ + textInteractionSource?: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined /** Hide reasoning that belongs to the Turn-level process disclosure. */ @@ -54,7 +56,7 @@ export interface AssistantMarkdownProps extends /** Renders non-blank reasoning blocks as independent disclosures alongside the response. */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, loadImage, groupPart, useDisclosure, + blocks, streaming, textInteractionSource = false, interrupted, loadImage, groupPart, useDisclosure, reasoningHidden = false, usePresentation, revealProcess, mentions, renderSlot, useGroupAction, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object @@ -88,6 +90,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ labels={labels} fileMentions={mentions} pathImages={pathImages} + textInteractionSource={textInteractionSource} />, ) break @@ -101,7 +104,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ useGroupAction={useGroupAction} > + useDisclosure={useDisclosure} renderSlot={renderSlot} textInteractionSource={textInteractionSource} t={t} /> , ) break diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 9f8cf6cbe6..455f90f9a2 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -40,6 +40,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({ groupPart={groupPart} useDisclosure={useDisclosure} streaming={data.status === 'running'} + textInteractionSource={data.status === 'settled' && data.finalNode?.messageId !== undefined} interrupted={data.status === 'interrupted'} loadImage={loadImage} reasoningHidden={reasoningHidden} diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 8c5bb1171a..5c28650c35 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -55,6 +55,7 @@ centered on the same axis as the input box (which caps at chat + 16px); the scroller itself stays full-bleed. */ .column { + position: relative; max-width: var(--dsh-chat-content-width); width: 100%; margin: 0 auto; @@ -66,10 +67,10 @@ `hidden="until-found"` retains a searchable zero-height box; hidden and empty Seats do not contribute spacing. The older-history control precedes the anchor; an ancestor :has() here also invalidates the history on unrelated descendant edits. */ -:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) - ~ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))), +:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) + ~ :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))), .column > .older + :global([data-slot="conversation.chat.flow"]) - > :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) { + > :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) { margin-top: var(--dsh-chat-flow-gap, 6px); } @@ -427,3 +428,13 @@ .modalAction { min-width: 72px; } + +/* Local surfaces do not contribute flex spacing or selected message text. */ +.column > .textInteraction { + position: absolute; + /* Remain below the sticky composer while scroll placement catches up. */ + z-index: 6; + visibility: hidden; + margin: 0; + width: max-content; +} diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 1e0741f31f..3fb6ca18c6 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -15,6 +15,7 @@ import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '.. import type { ChatSnapshot } from '../contract/snapshot.ts' import { TurnNavigator } from './TurnNavigator.tsx' import { mergeTurnRailItems } from './turn-rail-items.ts' +import { useTextInteraction } from './text-interaction.ts' import { useChatScroll } from './use-chat-scroll.ts' import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import css from './ChatView.module.css' @@ -60,7 +61,7 @@ const MOTION_TAIL_MS = 800 export function ChatView({ useSession, useChat, useConversation, useSessions, renderSlot, sessionId, openFile, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, - usePresentation, useProjection, t, + usePresentation, useProjection, useInteractionIds, dispatchInteraction, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries) @@ -192,6 +193,9 @@ export function ChatView({ loadedTurns: turnNavigationItems, transcriptView, }) + const interactionSurfaceRef = useRef(null) + const interactionIds = useInteractionIds(ids => ids) + const textInteraction = useTextInteraction(scroll.columnRef, interactionSurfaceRef, dispatchInteraction, interactionIds) const flowContext = useMemo(() => ({ motion: scroll.motion }), [scroll.motion]) return ( @@ -229,6 +233,13 @@ export function ChatView({ cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, fileMentions, }, { hookContext: flowContext })} +
+ {textInteraction.active !== null && renderSlot('conversation.chat.text-interaction', { + trigger: textInteraction.active.trigger, readText: textInteraction.readText, + dismiss: textInteraction.dismiss, clearSelection: textInteraction.clearSelection, + }, { entryKey: textInteraction.active.id })} +
{/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 8bbd738f55..347ce6bee4 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -45,9 +45,12 @@ function latestCompletedParagraphFirstLine(text: string): string { * @param props.t - conversation locale seat for the disclosure and running status. * @returns the reasoning disclosure. */ -export const ReasoningRow = memo(function ReasoningRow({ text, running, usePresentation, useDisclosure, renderSlot, t }: { +export const ReasoningRow = memo(function ReasoningRow({ + text, running, textInteractionSource = false, usePresentation, useDisclosure, renderSlot, t, +}: { text: string running: boolean + textInteractionSource?: boolean useDisclosure: UseDisclosure usePresentation: UsePresentation t: ChatViewSlotProps['t'] @@ -68,10 +71,10 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese ), [running, summary]) const content = useMemo(() => expanded ? ( -
+
{renderSlot('conversation.chat.reasoning.body', { text, running })}
- ) : undefined, [expanded, renderSlot, running, text]) + ) : undefined, [expanded, renderSlot, running, text, textInteractionSource]) return (
) { ))}
)} - {showBubble &&
+ {showBubble &&
{renderFactorySlot('conversation.user.text', { text, facts, interactive, ellipsis })} {extraBlocks.map((block, index) => ( diff --git a/packages/client/ui-chat/src/client/chat/text-interaction.ts b/packages/client/ui-chat/src/client/chat/text-interaction.ts new file mode 100644 index 0000000000..f497b66952 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/text-interaction.ts @@ -0,0 +1,298 @@ +/** Area-local text gestures, action ownership, dismissal, and content-relative placement. */ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { RefObject } from 'react' +import { readTextSelection, selectedText } from './text-selection.ts' +import type { TextSelection } from './text-selection.ts' + +import type { + TextInteractionGesture, TextInteractionSurface, TextInteractionTrigger, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +/** Current area-owned surface and its actions. */ +export interface TextInteractionBinding extends Omit { + readonly active: { readonly id: string; readonly trigger: TextInteractionTrigger } | null +} + +interface Target { + readonly source: HTMLElement + readonly selection: TextSelection | null + readonly text: string | null + readonly point: { readonly x: number; readonly y: number } | null +} + +interface Bounds { left: number; top: number; right: number; bottom: number } + +function rectBounds(rect: DOMRect): Bounds { + return { left: rect.left, top: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height } +} + +function intersects(a: Bounds, b: Bounds): boolean { + return a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom +} + +function sameRange(a: Range | null, b: Range | null): boolean { + return a === b || (a !== null && b !== null && a.startContainer === b.startContainer + && a.startOffset === b.startOffset && a.endContainer === b.endContainer && a.endOffset === b.endOffset) +} + +function available(root: HTMLElement, target: Target): boolean { + if (!root.isConnected || !root.contains(target.source) || !target.source.hasAttribute('data-text-interaction-source')) return false + const view = root.ownerDocument.defaultView + /* v8 ignore if -- Mounted browser content always has an owner window. */ + if (view === null) return false + for (let node: HTMLElement | null = target.source; node !== null; node = node.parentElement) { + const style = view.getComputedStyle(node) + if (node.hidden || node.inert || node.hasAttribute('data-text-interaction-exclude') + || style.display === 'none' || style.visibility === 'hidden') return false + } + return target.selection === null || (target.source.contains(target.selection.range.startContainer) + && target.source.contains(target.selection.range.endContainer) && selectedText(target.selection) === target.text) +} + +function composerSeat(root: HTMLElement): HTMLElement | null { + return root.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]') ?? null +} + +function visibleBounds(root: HTMLElement, source: HTMLElement, view: Window): Bounds { + const rootRect = rectBounds(root.getBoundingClientRect()) + const bounds: Bounds = { + left: Math.max(0, rootRect.left), right: Math.min(view.innerWidth, rootRect.right), top: 0, bottom: view.innerHeight, + } + for (let node: HTMLElement | null = source; node !== null; node = node.parentElement) { + const style = view.getComputedStyle(node) + const rect = rectBounds(node.getBoundingClientRect()) + if (/(auto|scroll|hidden|clip)/u.test(style.overflowX)) { + bounds.left = Math.max(bounds.left, rect.left) + bounds.right = Math.min(bounds.right, rect.right) + } + if (/(auto|scroll|hidden|clip)/u.test(style.overflowY)) { + bounds.top = Math.max(bounds.top, rect.top) + bounds.bottom = Math.min(bounds.bottom, rect.bottom) + } + } + const composer = composerSeat(root)?.getBoundingClientRect() + if (composer !== undefined && composer.height > 0 + && composer.right > bounds.left && composer.left < bounds.right) { + bounds.bottom = Math.min(bounds.bottom, composer.top) + } + return bounds +} + +/** + * Own text interactions for one positioned content area and its local surface. + * @param rootRef - existing content element with position: relative. + * @param surfaceRef - absolute surface inside that element; only the selected Slot consumer is mounted. + * @param dispatchInteraction - framework-injected plugin gesture dispatch. + * @param registeredIds - live registered owners used to dismiss removed plugins. + * @returns current owner and area-local selection actions. + */ +export function useTextInteraction( + rootRef: RefObject, surfaceRef: RefObject, + dispatchInteraction: (gesture: TextInteractionGesture) => string | null, + registeredIds: readonly string[], +): TextInteractionBinding { + const target = useRef(null) + const [active, setActive] = useState(null) + const dismiss = useCallback(() => { + target.current = null + if (surfaceRef.current !== null) surfaceRef.current.style.visibility = 'hidden' + setActive(null) + }, [surfaceRef]) + useEffect(dismiss, [registeredIds, dispatchInteraction, dismiss]) + const readText = useCallback(() => { + const root = rootRef.current + const current = target.current + if (root === null || current === null || !available(root, current)) return null + return current.selection === null ? null : selectedText(current.selection) + }, [rootRef]) + const clearSelection = useCallback(() => { rootRef.current?.ownerDocument.getSelection()?.removeAllRanges() }, [rootRef]) + + const place = useCallback(() => { + const root = rootRef.current + const surface = surfaceRef.current + const current = target.current + if (root === null || surface === null || current === null) return + if (!available(root, current)) { dismiss(); return } + const view = root.ownerDocument.defaultView + /* v8 ignore if -- Mounted browser content always has an owner window. */ + if (view === null) return + const visible = visibleBounds(root, current.source, view) + const selectionRects = current.selection === null ? [] : [...current.selection.range.getClientRects()] + const fallback = current.selection?.range.getBoundingClientRect() ?? current.source.getBoundingClientRect() + const rects = selectionRects.length === 0 ? [fallback] : selectionRects + const sourceRect = current.source.getBoundingClientRect() + const pointX = current.point === null ? 0 : sourceRect.left + current.point.x - current.source.scrollLeft + const pointY = current.point === null ? 0 : sourceRect.top + current.point.y - current.source.scrollTop + const anchor = current.point === null + ? rects.map(rectBounds).find(rect => intersects(rect, visible)) + : { left: pointX, right: pointX + 1, top: pointY, bottom: pointY + 1 } + if (anchor === undefined || !intersects(anchor, visible) || visible.right <= visible.left || visible.bottom <= visible.top) { + surface.style.visibility = 'hidden' + return + } + const gap = 8 + surface.style.maxWidth = `${visible.right - visible.left}px` + surface.style.maxHeight = `${visible.bottom - visible.top}px` + const size = surface.getBoundingClientRect() + const x = current.point === null + ? (Math.max(anchor.left, visible.left) + Math.min(anchor.right, visible.right) - size.width) / 2 + : anchor.left + const above = anchor.top - gap - size.height + const below = anchor.bottom + gap + const preferredY = current.point === null && above >= visible.top ? above : below + const left = Math.max(visible.left, Math.min(x, visible.right - size.width)) + const top = Math.max(visible.top, Math.min(preferredY, visible.bottom - size.height)) + const rootRect = root.getBoundingClientRect() + const scaleX = root.offsetWidth === 0 ? 1 : rootRect.width / root.offsetWidth + const scaleY = root.offsetHeight === 0 ? 1 : rootRect.height / root.offsetHeight + surface.style.left = `${(left - rootRect.left) / scaleX - root.clientLeft + root.scrollLeft}px` + surface.style.top = `${(top - rootRect.top) / scaleY - root.clientTop + root.scrollTop}px` + surface.style.visibility = 'visible' + }, [dismiss, rootRef, surfaceRef]) + + // The surface's measured dimensions are needed after its Slot owner renders, before paint. + useLayoutEffect(place, [active, place]) + useEffect(() => { + const root = rootRef.current + const surface = surfaceRef.current + if (root === null || surface === null) return + const document = root.ownerDocument + const view = document.defaultView + /* v8 ignore if -- Mounted browser content always has an owner window. */ + if (view === null) return + let pointerSelecting = false + let keyboardSelecting = false + let selectionChangedDuringGesture = false + let previousRange: Range | null = null + let frame: number | undefined + const schedulePlacement = (): void => { + if (target.current === null || frame !== undefined) return + frame = view.requestAnimationFrame(() => { frame = undefined; place() }) + } + const dispatch = (trigger: TextInteractionTrigger, next: Target, event: Event): void => { + if (!available(root, next)) return + const id = dispatchInteraction({ + trigger, hasSelection: next.selection !== null, + readText: () => next.text, + preventDefault: () => { event.preventDefault() }, + }) + if (id === null) return + target.current = next + setActive({ id, trigger }) + } + const updateSelection = (event: Event): void => { + if (pointerSelecting || keyboardSelecting) { selectionChangedDuringGesture = true; return } + if (target.current !== null && target.current.point !== null) return + if (target.current !== null && surface.contains(document.activeElement)) return + const selection = readTextSelection(root, document.getSelection()) + const range = selection?.range ?? null + if (!selectionChangedDuringGesture && sameRange(previousRange, range)) return + selectionChangedDuringGesture = false + previousRange = range?.cloneRange() ?? null + dismiss() + if (selection === null) return + const text = selectedText(selection) + if (text === null) return + dispatch('selection', { source: selection.source, selection: { ...selection, range: selection.range.cloneRange() }, text, point: null }, event) + } + const insideSurface = (event: Event): boolean => event.composedPath().includes(surface) + const pointerDown = (event: PointerEvent): void => { + if (insideSurface(event)) return + const selection = document.getSelection() + previousRange = selection?.rangeCount === 1 ? selection.getRangeAt(0).cloneRange() : null + dismiss() + selectionChangedDuringGesture = false + pointerSelecting = event.button === 0 && event.target instanceof Node && root.contains(event.target) + } + const pointerUp = (event: PointerEvent): void => { + if (!pointerSelecting) return + pointerSelecting = false + updateSelection(event) + } + const cancel = (): void => { pointerSelecting = false; keyboardSelecting = false; dismiss() } + const keyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { cancel(); return } + if (insideSurface(event) || !(event.target instanceof Node) || !root.contains(event.target)) return + if (event.key === 'Shift' || event.shiftKey) { keyboardSelecting = true; dismiss() } + } + const keyUp = (event: KeyboardEvent): void => { + if (!keyboardSelecting || (event.key !== 'Shift' && event.shiftKey)) return + keyboardSelecting = false + updateSelection(event) + } + const contextMenu = (event: MouseEvent): void => { + if (insideSurface(event)) return + const element = event.target instanceof Element ? event.target : null + const source = element?.closest('[data-text-interaction-source]') + if (source === undefined || source === null || !root.contains(source) + || element?.closest('[data-text-interaction-exclude], button, input, textarea, [contenteditable="true"]')) return + pointerSelecting = false + dismiss() + const selected = readTextSelection(root, document.getSelection()) + const keyboard = event.clientX === 0 && event.clientY === 0 + const inSelection = selected?.source === source && (keyboard || [...selected.range.getClientRects()].some(rect => + event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom)) + const selection = inSelection ? selected : null + const bounds = source.getBoundingClientRect() + dispatch('contextmenu', { + source, selection: selection === null ? null : { ...selection, range: selection.range.cloneRange() }, + text: selection === null ? null : selectedText(selection), + point: { + x: (keyboard ? 0 : event.clientX - bounds.left) + source.scrollLeft, + y: (keyboard ? 0 : event.clientY - bounds.top) + source.scrollTop, + }, + }, event) + } + document.addEventListener('selectionchange', updateSelection) + document.addEventListener('pointerdown', pointerDown, true) + document.addEventListener('pointerup', pointerUp) + document.addEventListener('pointercancel', cancel) + document.addEventListener('keydown', keyDown) + document.addEventListener('keyup', keyUp) + document.addEventListener('scroll', schedulePlacement, true) + root.addEventListener('contextmenu', contextMenu) + view.addEventListener('resize', schedulePlacement) + view.addEventListener('blur', cancel) + return () => { + document.removeEventListener('selectionchange', updateSelection) + document.removeEventListener('pointerdown', pointerDown, true) + document.removeEventListener('pointerup', pointerUp) + document.removeEventListener('pointercancel', cancel) + document.removeEventListener('keydown', keyDown) + document.removeEventListener('keyup', keyUp) + document.removeEventListener('scroll', schedulePlacement, true) + root.removeEventListener('contextmenu', contextMenu) + view.removeEventListener('resize', schedulePlacement) + view.removeEventListener('blur', cancel) + if (frame !== undefined) view.cancelAnimationFrame(frame) + } + }, [dismiss, place, rootRef, surfaceRef, dispatchInteraction]) + useEffect(() => { + const root = rootRef.current + const surface = surfaceRef.current + if (active === null || root === null || surface === null) return + const view = root.ownerDocument.defaultView + /* v8 ignore if -- Mounted browser content always has an owner window. */ + if (view === null) return + let frame: number | undefined + const schedule = (): void => { + if (frame !== undefined) return + frame = view.requestAnimationFrame(() => { frame = undefined; place() }) + } + const resize = new ResizeObserver(schedule) + resize.observe(root) + resize.observe(surface) + const composer = composerSeat(root) + if (composer !== null) resize.observe(composer) + const mutations = new MutationObserver((records) => { + if (records.some(record => !surface.contains(record.target))) schedule() + }) + mutations.observe(root, { childList: true, subtree: true, characterData: true, attributes: true }) + return () => { + resize.disconnect() + mutations.disconnect() + if (frame !== undefined) view.cancelAnimationFrame(frame) + } + }, [active, place, rootRef, surfaceRef]) + return { active, dismiss, readText, clearSelection } +} diff --git a/packages/client/ui-chat/src/client/chat/text-selection.ts b/packages/client/ui-chat/src/client/chat/text-selection.ts new file mode 100644 index 0000000000..bc3ae4dec0 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/text-selection.ts @@ -0,0 +1,180 @@ +/** DOM selection validation and visible-text projection, independent of business actions. */ + +const PROJECTED_BLOCK_TAGS = new Set([ + 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DD', 'DIV', 'DL', 'DT', 'FIGCAPTION', + 'FIGURE', 'FOOTER', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'LI', 'MAIN', + 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TFOOT', 'THEAD', 'TR', 'UL', +]) +const PROJECTED_CELL_TAGS = new Set(['TD', 'TH']) + +function excluded(element: Element): boolean { + if (element.matches('.katex-mathml, [data-text-interaction-exclude], [hidden], .sr-only')) return true + if (element.matches('[aria-hidden="true"]') && !element.matches('.katex-html')) return true + return element.tagName === 'BUTTON' && element.parentElement?.tagName !== 'CODE' +} + +function projectedBoundary(node: Node): '\n' | '\t' | '' { + if (!(node instanceof Element)) return '' + if (PROJECTED_CELL_TAGS.has(node.tagName)) return '\t' + return PROJECTED_BLOCK_TAGS.has(node.tagName) ? '\n' : '' +} + +function projectedSeparator(previous: Node, current: Node): '\n' | '\t' | '' { + const left = projectedBoundary(previous) + const right = projectedBoundary(current) + if (left === '\n' || right === '\n') return '\n' + return left === '\t' || right === '\t' ? '\t' : '' +} + +interface ProjectedText { + readonly text: string + readonly offsets: readonly number[] +} + +function projectedText(node: Text): ProjectedText { + const source = node.data + const parent = node.parentElement + const view = node.ownerDocument.defaultView + const whiteSpace = parent === null || view === null ? '' : view.getComputedStyle(parent).whiteSpace + const preservesBreaks = whiteSpace === 'pre' || whiteSpace === 'pre-wrap' + || whiteSpace === 'pre-line' || whiteSpace === 'break-spaces' + const offsets = new Array(source.length + 1).fill(0) + const structuralWhitespace = !preservesBreaks + && /^[\t\n\f\r ]*$/u.test(source) + && [node.previousSibling, node.nextSibling].some( + sibling => sibling !== null && projectedBoundary(sibling) !== '', + ) + if (structuralWhitespace) return { text: '', offsets } + let text = '' + let cursor = 0 + if (!preservesBreaks && node.previousSibling instanceof HTMLElement + && node.previousSibling.tagName === 'BR' && source.startsWith('\n')) { + cursor = 1 + } + while (cursor < source.length) { + const softBreak = preservesBreaks ? null : /^[ \t]*[\r\n]+[ \t]*/u.exec(source.slice(cursor)) + if (softBreak !== null) { + const end = cursor + softBreak[0].length + for (let index = cursor; index < end; index++) offsets[index] = text.length + text += ' ' + offsets[end] = text.length + cursor = end + continue + } + offsets[cursor] = text.length + text += source.charAt(cursor) + cursor += 1 + offsets[cursor] = text.length + } + return { text, offsets } +} + +interface ProjectedTextSpan { + readonly node: Text + readonly start: number + readonly offsets: readonly number[] +} + +interface ProjectedFragment { + readonly text: string + readonly spans: readonly ProjectedTextSpan[] + readonly boundaries: readonly (readonly [Node, readonly number[]])[] +} + +function projectedFragment(node: Node): ProjectedFragment { + if (node.nodeType === Node.TEXT_NODE) { + const projected = projectedText(node as Text) + return { + text: projected.text, + spans: projected.offsets.length <= 1 + ? [] + : [{ node: node as Text, start: 0, offsets: projected.offsets }], + boundaries: [], + } + } + if (!(node instanceof Element)) { + return { text: '', spans: [], boundaries: [] } + } + if (excluded(node)) return { text: '', spans: [], boundaries: [] } + if (node.tagName === 'BR') return { text: '\n', spans: [], boundaries: [] } + let text = '' + let previous: Node | null = null + const spans: ProjectedTextSpan[] = [] + const boundaries: Array = [] + const offsets = new Array(node.childNodes.length + 1).fill(0) + for (const [index, child] of [...node.childNodes].entries()) { + const fragment = projectedFragment(child) + if (fragment.text !== '' && previous !== null) { + const separator = projectedSeparator(previous, child) + const needsSeparator = separator === '\n' + ? !text.endsWith('\n') && !fragment.text.startsWith('\n') + : separator === '\t' && !/[\t\n]$/u.test(text) && !/^[\t\n]/u.test(fragment.text) + if (needsSeparator) text += separator + } + offsets[index] = text.length + const base = text.length + text += fragment.text + for (const span of fragment.spans) { + spans.push({ node: span.node, start: base + span.start, offsets: span.offsets }) + } + for (const [owner, childOffsets] of fragment.boundaries) { + boundaries.push([owner, childOffsets.map(offset => base + offset)]) + } + if (fragment.text !== '') previous = child + offsets[index + 1] = text.length + } + boundaries.push([node, offsets]) + return { text, spans, boundaries } +} + +function projectedOffset( + projection: ProjectedFragment, + container: Node, + offset: number, +): number | null { + if (container.nodeType === Node.TEXT_NODE) { + const span = projection.spans.find(candidate => candidate.node === container) + const projected = span?.offsets[offset] + return span === undefined || projected === undefined ? null : span.start + projected + } + const boundaries = projection.boundaries.find(([owner]) => owner === container)?.[1] + return boundaries?.[offset] ?? null +} + +/** One browser range contained by an enabled text source. */ +export interface TextSelection { + readonly source: HTMLElement + readonly range: Range +} + +/** + * Find a single noncollapsed selection inside one enabled source. + * @param root - mounted interaction area. + * @param selection - browser selection in that area's document. + * @returns the selected source and range, or null across sources and excluded content. + */ +export function readTextSelection(root: HTMLElement, selection: Selection | null): TextSelection | null { + if (selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return null + const range = selection.getRangeAt(0) + const sourceOf = (node: Node): HTMLElement | null => + (node instanceof Element ? node : node.parentElement)?.closest('[data-text-interaction-source]') ?? null + const source = sourceOf(range.startContainer) + if (source === null || source !== sourceOf(range.endContainer) || !root.contains(source) + || source.closest('[hidden], [inert], [data-text-interaction-exclude]') !== null) return null + return { source, range } +} + +/** + * Project selected DOM text with visible block boundaries and without renderer chrome. + * @param selection - range and its source, obtained through readTextSelection. + * @returns trimmed visible text; empty or excluded selections return null. + */ +export function selectedText(selection: TextSelection): string | null { + const { source, range } = selection + const projection = projectedFragment(source) + const start = projectedOffset(projection, range.startContainer, range.startOffset) + const end = projectedOffset(projection, range.endContainer, range.endOffset) + if (start === null || end === null) return null + const text = projection.text.slice(start, end).trim() + return text === '' ? null : text +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 4bd88840f6..fc4be216cc 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -7,6 +7,7 @@ import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, + TextInteractionGesture, TextInteractionSurface, ConversationGroupData, GroupSnapshot, MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderEntry, TurnLocation, UserTextFacts, UserTextInput, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -297,7 +298,11 @@ export interface PerformanceUsageInjected { /** Business callbacks injected into the Chat view. */ export interface ChatViewInjected { + /** Dispatch one gesture to the Conversation plugin registry. */ + dispatchInteraction: (gesture: TextInteractionGesture) => string | null hooks: { + /** Live plugin IDs; removed plugins dismiss their active surface. */ + interactionIds: ObservableSnapshot /** Live presentation policy derived from the accepted work-details mode. */ presentation: ObservableSnapshot } @@ -328,7 +333,7 @@ export interface ChatViewInjected { /** Session-owned node sources shared with the view, plus the flow's connection state. */ export type ChatFlowDataInjected = Pick & { - hooks: ChatViewInjected['hooks'] & { + hooks: Pick & { /** Client connection lifecycle; the running clock pauses while it is not connected. */ connectionState: ConnectionStateSource } @@ -355,7 +360,7 @@ export type ChatFlowSlotProps = /** Full Chat view props. */ export type ChatViewSlotProps = PropsRuntime<'conversation.view'> - & PropsRenderSlots<'conversation.chat.flow'> + & PropsRenderSlots<'conversation.chat.flow' | 'conversation.chat.text-interaction'> & PropsStore & InjectFace & PropsLocale<'chat'> @@ -392,6 +397,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } interface SlotMap { + /** Area-local text gestures and surfaces; each consumer registers its own synchronous admission. */ + 'conversation.chat.text-interaction': { kind: 'keyed'; scope: 'session'; owner: TextInteractionSurface } + /** Ordered Chat rows and local echoes with viewport-bound visibility hooks. */ 'conversation.chat.flow': { kind: 'single' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 967b5446c1..fb7684a8f7 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -126,6 +126,7 @@ export const messageDefinition: ConversationNodeDefinition = { } : { kind: 'user', + messageId: event.data.id, seq: event.seq, time: event.time, content: event.data.content, diff --git a/packages/client/ui-chat/src/client/settings/PreferenceRow.module.css b/packages/client/ui-chat/src/client/settings/PreferenceRow.module.css index dfcb95648d..142f42af73 100644 --- a/packages/client/ui-chat/src/client/settings/PreferenceRow.module.css +++ b/packages/client/ui-chat/src/client/settings/PreferenceRow.module.css @@ -17,6 +17,14 @@ padding-right: 48px; } +/* Narrow settings panel: the 48px gutter separates the label from its control + across a wide row and only squeezes the label here. */ +@media (max-width: 767px) { + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + padding-right: 16px; + } +} + .title { font-size: 14px; font-weight: 400; diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 3bdd5e706b..c89f2c88b3 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -138,6 +138,50 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, } describe('Chat inject API', () => { + it('routes text gestures from each Chat view to the registered consumer with its own Session', async () => { + const b = await bench() + try { + const secondId = await b.runtime.sessions.add({ id: 'second-chat' }) + using secondReference = b.runtime.sessions.retain(secondId) + await secondReference.ready + const first = b.chatViewApi(b.rootReference).injected + const second = b.chatViewApi(secondReference).injected + const received: { sessionId: SessionId; text: string | null }[] = [] + const dispose = b.runtime.ctx.uiConversation.interactions.register({ + id: 'selection-menu', + onInteraction(request) { + received.push({ sessionId: request.sessionId, text: request.readText() }) + if (request.trigger !== 'contextmenu') return + request.preventDefault() + request.open() + }, + }) + expect(first.hooks.interactionIds.getSnapshot()).toEqual(['selection-menu']) + expect(second.hooks.interactionIds.getSnapshot()).toEqual(['selection-menu']) + const nativeMenu = vi.fn() + expect(first.dispatchInteraction({ + trigger: 'selection', hasSelection: true, readText: () => 'first selection', preventDefault: nativeMenu, + })).toBeNull() + expect(nativeMenu).not.toHaveBeenCalled() + const contextMenu = { + trigger: 'contextmenu' as const, hasSelection: true, + readText: () => 'second selection', preventDefault: nativeMenu, + } + expect(second.dispatchInteraction(contextMenu)).toBe('selection-menu') + expect(nativeMenu).toHaveBeenCalledOnce() + expect(received).toEqual([ + { sessionId: ROOT, text: 'first selection' }, + { sessionId: secondId, text: 'second selection' }, + ]) + dispose() + expect(first.hooks.interactionIds.getSnapshot()).toEqual([]) + expect(second.dispatchInteraction(contextMenu)).toBeNull() + expect(nativeMenu).toHaveBeenCalledOnce() + } finally { + await b.runtime.dispose() + } + }) + it('projects independent Settings choices and saves timing to Host settings', async () => { const b = await bench() try { diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index de1f0688dd..aeba01b380 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -161,8 +161,8 @@ describe('chat flow font-size axis', () => { const empty = declarationsFrom(css, emptySelector) expect(empty).toContain('height: 0') expect(empty).not.toContain('display: none') - expect(css).toContain(`:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(${emptySelector})`) - expect(css).toContain(`~ :not([hidden]):not(${emptySelector})`) + expect(css).toContain(`:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.textInteraction):not(${emptySelector})`) + expect(css).toContain(`~ :not([hidden]):not(.textInteraction):not(${emptySelector})`) }) it('the interrupted-turn tag stays fixed like the dense token variants', () => { diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 078181f3f3..f1840034d4 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -260,7 +260,7 @@ function bindKeyedSnapshotSelector( } declare const nodeDispatch: PropsRenderSlots<'conversation.chat.node'>['renderSlot'] -declare const flowDispatch: ChatViewSlotProps['renderSlot'] +declare const flowDispatch: PropsRenderSlots<'conversation.chat.flow'>['renderSlot'] type NodeRenderer = typeof nodeDispatch<'conversation.chat.node'> type FlowRenderer = typeof flowDispatch<'conversation.chat.flow'> type ImageRenderer = (owner: MessageImagesOwnerProps) => React.ReactNode @@ -453,7 +453,8 @@ function makeHarness( if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected Chat Factory: ${name}`) return renderUserContent(owner as UserContentInput, renderImages, props.t) } - const renderSlot: FlowRenderer = (key, owner, opts) => { + const renderSlot = (key: string, owner: ChatFlowOwnerProps, opts: Parameters[2]) => { + if (key === 'conversation.chat.text-interaction') return null if (key !== 'conversation.chat.flow') throw new Error(`Undeclared ChatView child: ${key}`) if ('renderSlot' in owner || 'useStore' in owner || 'actions' in owner) { throw new Error('Flow owner data must not replace its rendering or store authority') @@ -492,6 +493,7 @@ function makeHarness( }, useInput: (() => { throw new Error('unused') }), inputActions: { + insertQuote: () => false, captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), insertText: () => false, setDraft: () => {}, @@ -503,6 +505,8 @@ function makeHarness( }, useStore: bindSnapshotSelector(chat), actions: chat.actions, + useInteractionIds: bindSnapshotSelector(createSnapshotStore([])), + dispatchInteraction: () => null, usePresentation: bindSnapshotSelector(derivePresentationPolicy(transcriptView, collapseTiming, foldedReplies)), // The fixture implements the concrete flow dispatch behind the generic slot signature. renderSlot: renderSlot as ChatViewSlotProps['renderSlot'], @@ -4575,7 +4579,7 @@ describe('ChatView', () => { const view = render() const column = view.container.querySelector('[data-chat-flow]')! const status = column.querySelector('[data-chat-running]') - expect(column.lastElementChild).toBe(status) + expect([...column.children].filter(child => !child.hasAttribute('data-text-interaction-surface')).at(-1)).toBe(status) expect(within(status as HTMLElement).getByRole('status').textContent).toBe('深度求索中') expect(status?.lastElementChild?.textContent).toBe('深度求索中') const icon = status?.querySelector('svg')?.parentElement diff --git a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index 241a0faded..2d4043f261 100644 --- a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -16,6 +16,26 @@ const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) afterEach(cleanup) describe('tails', () => { + it('enables quoting on the existing Markdown root without replacing streamed content', () => { + const body = (textInteractionSource: boolean, streaming: boolean) => ( + null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden} + usePresentation={useDetailedPresentation} t={t} loadImage={unusedImageLoader} + blocks={[{ kind: 'text', text: 'source words' }]} textInteractionSource={textInteractionSource} streaming={streaming} /> + ) + const view = render(body(false, true)) + const paragraph = view.getByText('source words') + const markdown = paragraph.parentElement + expect(view.container.querySelector('[data-text-interaction-source]')).toBeNull() + + view.rerender(body(true, false)) + expect(view.container.querySelector('[data-text-interaction-source]')).toBe(markdown) + expect(view.getByText('source words')).toBe(paragraph) + + view.rerender(body(false, false)) + expect(view.container.querySelector('[data-text-interaction-source]')).toBeNull() + expect(view.getByText('source words').parentElement).toBe(markdown) + }) + it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden} diff --git a/packages/client/ui-chat/tests/text-interaction.client.spec.tsx b/packages/client/ui-chat/tests/text-interaction.client.spec.tsx new file mode 100644 index 0000000000..2d8a1d6426 --- /dev/null +++ b/packages/client/ui-chat/tests/text-interaction.client.spec.tsx @@ -0,0 +1,374 @@ +// @vitest-environment jsdom +import { createRef, useCallback, useMemo, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, renderHook } from '@testing-library/react' +import { useTextInteraction } from '../src/client/chat/text-interaction.ts' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TextInteractionBinding } from '../src/client/chat/text-interaction.ts' +import type { TextInteractionGesture, TextInteractionRequest } from '@deepseek-ai/dsh-client-ui-conversation/client' + +const rect = (left: number, top: number, width: number, height: number): DOMRect => new DOMRect(left, top, width, height) +let selectionRect = rect(100, 120, 40, 20) +let resize: () => void + +beforeEach(() => { + selectionRect = rect(100, 120, 40, 20) + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe() {} + disconnect() {} + }) + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { configurable: true, value: () => selectionRect }) + Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: () => Object.assign([selectionRect], { item: (i: number) => i === 0 ? selectionRect : null }) }) + vi.stubGlobal('PointerEvent', MouseEvent) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.surface !== undefined) return rect(0, 0, 80, 30) + if (this.dataset.viewport !== undefined) return rect(20, 80, 400, 300) + return rect(40, 100, 300, 500) + }) +}) + +afterEach(() => { + cleanup() + window.getSelection()?.removeAllRanges() + Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect') + Reflect.deleteProperty(Range.prototype, 'getClientRects') + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function Area({ id = 'action', admit, onBinding, enabled = true, composer = false }: { + enabled?: boolean + composer?: boolean + id?: string + admit?: (request: TextInteractionRequest) => void + onBinding?: (binding: TextInteractionBinding) => void +}) { + const root = useRef(null) + const surface = useRef(null) + const ids = useMemo(() => enabled ? [id] : [], [enabled, id]) + const dispatch = useCallback((gesture: TextInteractionGesture): string | null => { + if (!enabled) return null + let opened = false + const request: TextInteractionRequest = { ...gesture, sessionId: SessionId(id), open: () => { opened = true } } + if (admit === undefined) { if (request.trigger === 'selection') request.open() } + else admit(request) + return opened ? id : null + }, [id, admit, enabled]) + const interaction = useTextInteraction(root, surface, dispatch, ids) + onBinding?.(interaction) + return
+
+

Hello {id}

+

Other text

+
+ {interaction.active?.id === id && } +
+
+ {composer &&
Composer and dock
} +
+} + +function select(source: Element) { + const range = document.createRange() + range.selectNodeContents(source) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + fireEvent(document, new Event('selectionchange')) + return selection +} + +async function layout() { + await act(async () => { resize(); await new Promise(resolve => requestAnimationFrame(resolve)) }) +} + +describe('area text interactions', () => { + it('waits for pointer and keyboard selection to finish', () => { + const view = render() + const source = view.container.querySelector('[data-text-interaction-source]')! + fireEvent.pointerDown(source, { button: 0 }) + select(source) + expect(view.queryByRole('button')).toBeNull() + fireEvent.pointerUp(document) + expect(view.getByRole('button', { name: 'action' })).toBeTruthy() + fireEvent.keyDown(source, { key: 'Shift', shiftKey: true }) + expect(view.queryByRole('button')).toBeNull() + const range = window.getSelection()!.getRangeAt(0) + range.setEnd(source.firstChild!, 3) + fireEvent(document, new Event('selectionchange')) + fireEvent.keyUp(source, { key: 'Shift' }) + expect(view.getByRole('button')).toBeTruthy() + }) + + it('closes outside without clearing selection or reopening on scroll and resize', async () => { + const view = render(<>) + const selection = select(view.container.querySelector('[data-text-interaction-source]')!) + const action = view.getByRole('button', { name: 'action' }) + fireEvent.pointerDown(action, { button: 0 }) + expect(view.getByRole('button', { name: 'action' })).toBeTruthy() + fireEvent.pointerDown(view.getByRole('button', { name: 'Outside' }), { button: 0 }) + expect(view.queryByRole('button', { name: 'action' })).toBeNull() + expect(selection.toString()).toBe('Hello action') + fireEvent.pointerUp(document) + fireEvent(document, new Event('selectionchange')) + fireEvent.scroll(view.container.querySelector('[data-viewport]')!) + await layout() + expect(view.queryByRole('button', { name: 'action' })).toBeNull() + }) + + it('dismisses on Escape, cancellation and blur', () => { + const view = render() + const sources = view.container.querySelectorAll('[data-text-interaction-source]') + select(sources[0]!) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('button')).toBeNull() + select(sources[1]!) + fireEvent.pointerCancel(document) + expect(view.queryByRole('button')).toBeNull() + select(sources[0]!) + fireEvent.blur(window) + expect(view.queryByRole('button')).toBeNull() + }) + + it('places in content coordinates, flips below at the top and restores after scrolling offscreen', async () => { + const view = render() + select(view.container.querySelector('[data-text-interaction-source]')!) + const surface = view.container.querySelector('[data-surface]')! + expect(surface.parentElement?.dataset.area).toBe('action') + expect(surface.style.left).toBe('40px') + expect(surface.style.top).toBe('-18px') + selectionRect = rect(100, 90, 40, 20) + await layout() + expect(surface.style.top).toBe('18px') + selectionRect = rect(100, 10, 40, 20) + fireEvent.scroll(view.container.querySelector('[data-viewport]')!) + await layout() + expect(view.queryByRole('button')).toBeNull() + expect(surface.querySelector('button')).not.toBeNull() + selectionRect = rect(100, 120, 40, 20) + fireEvent.scroll(view.container.querySelector('[data-viewport]')!) + await layout() + expect(view.getByRole('button')).toBeTruthy() + expect(surface.style.top).toBe('-18px') + }) + + it('hides behind the composer and its dock, restores on resize, and keeps explicit dismissal final', async () => { + const view = render() + const composer = view.container.querySelector('[data-composer-seat]')! + let composerTop = 260 + composer.getBoundingClientRect = () => rect(20, composerTop, 400, 380 - composerTop) + const source = view.container.querySelector('[data-text-interaction-source]')! + const selection = select(source) + const surface = view.container.querySelector('[data-surface]')! + const action = surface.querySelector('button')! + selectionRect = rect(100, 280, 40, 20) + await layout() + expect(surface.style.visibility).toBe('hidden') + expect(surface.querySelector('button')).toBe(action) + expect(selection.toString()).toBe('Hello action') + composerTop = 330 + await layout() + expect(view.getByRole('button')).toBe(action) + composerTop = 260 + await layout() + expect(surface.style.visibility).toBe('hidden') + fireEvent.pointerDown(composer, { button: 0 }) + composerTop = 330 + await layout() + expect(surface.querySelector('button')).toBeNull() + }) + + it('invalidates changed, disabled and removed sources', async () => { + const view = render() + const sources = view.container.querySelectorAll('[data-text-interaction-source]') + select(sources[0]!) + sources[0]!.firstChild!.textContent = 'Changed text' + await layout() + expect(view.queryByRole('button')).toBeNull() + select(sources[1]!) + sources[1]!.removeAttribute('data-text-interaction-source') + await layout() + expect(view.queryByRole('button')).toBeNull() + select(sources[0]!) + sources[0]!.remove() + await layout() + expect(view.queryByRole('button')).toBeNull() + }) + + it('isolates areas and removes listeners on unmount', () => { + const view = render(<>) + const areas = view.container.querySelectorAll('[data-area]') + select(areas[0]!.querySelector('[data-text-interaction-source]')!) + expect(view.getByRole('button', { name: 'first' })).toBeTruthy() + expect(view.queryByRole('button', { name: 'second' })).toBeNull() + select(areas[1]!.querySelector('[data-text-interaction-source]')!) + expect(view.queryByRole('button', { name: 'first' })).toBeNull() + expect(view.getByRole('button', { name: 'second' })).toBeTruthy() + view.unmount() + fireEvent(document, new Event('selectionchange')) + expect(document.querySelector('[data-surface]')).toBeNull() + }) + + it('leaves contextmenu defaults to the registrant and never reuses an unrelated selection', () => { + const admit = vi.fn((request: TextInteractionRequest) => { + if (request.trigger === 'contextmenu') { request.preventDefault(); request.open() } + }) + const view = render() + const sources = view.container.querySelectorAll('[data-text-interaction-source]') + select(sources[0]!) + const same = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 110, clientY: 130 }) + fireEvent(sources[0]!, same) + expect(same.defaultPrevented).toBe(true) + expect(admit.mock.lastCall![0].readText()).toBe('Hello action') + const other = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 110, clientY: 130 }) + fireEvent(sources[1]!, other) + expect(admit.mock.lastCall![0].hasSelection).toBe(false) + expect(admit.mock.lastCall![0].readText()).toBeNull() + view.rerender() + expect(view.queryByRole('button')).toBeNull() + const native = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 110, clientY: 130 }) + fireEvent(sources[0]!, native) + expect(native.defaultPrevented).toBe(false) + expect(view.queryByRole('button')).toBeNull() + }) +}) + + +describe('text interaction lifecycle', () => { + it('exposes DOM text only while the captured source remains usable', () => { + let binding: TextInteractionBinding | undefined + const view = render( { binding = value }} />) + expect(binding!.readText()).toBeNull() + const source = view.container.querySelector('[data-text-interaction-source]')! + const selection = select(source) + expect(binding!.readText()).toBe('Hello action') + source.hidden = true + expect(binding!.readText()).toBeNull() + source.hidden = false + source.removeAttribute('data-text-interaction-source') + expect(binding!.readText()).toBeNull() + source.setAttribute('data-text-interaction-source', '') + act(() => { binding!.clearSelection(); binding!.dismiss() }) + expect(selection.isCollapsed).toBe(true) + expect(view.queryByRole('button')).toBeNull() + expect(binding!.readText()).toBeNull() + view.unmount() + expect(binding!.readText()).toBeNull() + binding!.clearSelection() + }) + + it('supports an unmounted area', () => { + const root = createRef() + const surface = createRef() + const hook = renderHook(() => useTextInteraction(root, surface, () => null, [])) + expect(hook.result.current.readText()).toBeNull() + act(() => { hook.result.current.dismiss(); hook.result.current.clearSelection() }) + }) + + it('dismisses a surface when its plugin unregisters', () => { + const view = render() + select(view.container.querySelector('[data-text-interaction-source]')!) + expect(view.getByRole('button')).toBeTruthy() + view.rerender() + expect(view.queryByRole('button')).toBeNull() + }) + + it('opens context menus without cancelling defaults unless the consumer requests it', () => { + let binding: TextInteractionBinding | undefined + const view = render( { binding = value }} admit={(request) => { request.open() }} />) + const source = view.container.querySelector('[data-text-interaction-source]')! + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }) + fireEvent(source, event) + expect(event.defaultPrevented).toBe(false) + expect(view.getByRole('button')).toBeTruthy() + expect(binding!.readText()).toBeNull() + // Menu focus may collapse browser selection without closing the captured interaction. + view.getByRole('button').focus() + fireEvent(document, new Event('selectionchange')) + expect(view.getByRole('button')).toBeTruthy() + fireEvent.contextMenu(view.getByRole('button')) + expect(view.getByRole('button')).toBeTruthy() + }) + + it('keeps a focused selection action open and ignores unrelated keys', () => { + const view = render() + const source = view.container.querySelector('[data-text-interaction-source]')! + select(source) + const button = view.getByRole('button') + button.focus() + fireEvent(document, new Event('selectionchange')) + fireEvent.keyDown(button, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Shift' }) + fireEvent.keyUp(document, { key: 'a' }) + expect(view.getByRole('button')).toBeTruthy() + button.blur() + fireEvent.keyDown(source, { key: 'ArrowLeft' }) + fireEvent.keyDown(source, { key: 'Shift', shiftKey: true }) + fireEvent.keyUp(source, { key: 'ArrowLeft', shiftKey: true }) + expect(view.queryByRole('button')).toBeNull() + fireEvent.keyUp(source, { key: 'Shift' }) + }) + + it('does not admit blank or hidden selections, controls, or unmarked right-click targets', () => { + const admit = vi.fn((request: TextInteractionRequest) => { request.open() }) + const view = render() + const source = view.container.querySelector('[data-text-interaction-source]')! + source.textContent = ' ' + select(source) + expect(admit).not.toHaveBeenCalled() + source.textContent = 'visible words' + source.style.display = 'none' + select(source) + expect(admit).not.toHaveBeenCalled() + fireEvent.contextMenu(source) + expect(admit).not.toHaveBeenCalled() + source.style.display = '' + source.innerHTML = '' + fireEvent.contextMenu(source.firstElementChild!) + fireEvent.contextMenu(source.firstElementChild!.firstChild!) + fireEvent.contextMenu(view.container.querySelector('[data-area]')!) + expect(admit).not.toHaveBeenCalled() + }) + + it('invalidates folded ancestors and handles scaled positioning containers', async () => { + const view = render() + const area = view.container.querySelector('[data-area]')! + const source = area.querySelector('[data-text-interaction-source]')! + Object.defineProperties(area, { offsetWidth: { value: 150 }, offsetHeight: { value: 250 } }) + select(source) + const surface = area.querySelector('[data-surface]')! + expect(surface.style.left).toBe('20px') + expect(surface.style.top).toBe('-9px') + area.style.visibility = 'hidden' + await layout() + expect(view.queryByRole('button')).toBeNull() + }) + + it('keeps a context-menu point attached to its source through scrolling and reflow', async () => { + const view = render( { if (request.trigger === 'contextmenu') request.open() }} />) + const source = view.container.querySelector('[data-text-interaction-source]')! + let sourceTop = 100 + source.getBoundingClientRect = () => rect(40, sourceTop, 300, 500) + fireEvent.contextMenu(source, { clientX: 110, clientY: 130 }) + const surface = view.container.querySelector('[data-surface]')! + expect(surface.style.top).toBe('39px') + sourceTop = 80 + await layout() + expect(surface.style.top).toBe('19px') + source.scrollTop = 5 + fireEvent.scroll(source) + await layout() + expect(surface.style.top).toBe('14px') + }) + + it('cancels scheduled placement when an active area unmounts', () => { + const view = render() + select(view.container.querySelector('[data-text-interaction-source]')!) + fireEvent.scroll(view.container.querySelector('[data-viewport]')!) + resize() + view.unmount() + expect(document.querySelector('[data-surface]')).toBeNull() + }) +}) diff --git a/packages/client/ui-chat/tests/text-selection.client.spec.tsx b/packages/client/ui-chat/tests/text-selection.client.spec.tsx new file mode 100644 index 0000000000..63afd15753 --- /dev/null +++ b/packages/client/ui-chat/tests/text-selection.client.spec.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { readTextSelection, selectedText } from '../src/client/chat/text-selection.ts' + +function readSelection(root: HTMLElement, selection: Selection | null) { + const result = readTextSelection(root, selection) + return result === null ? null : { text: selectedText(result) } +} + +const markdownLabels = { + code: { copyLabel: 'Copy', copiedLabel: 'Copied' }, + footnotes: 'Footnotes', +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('quote selection', () => { + it('extracts one source block and omits Markdown control copy', () => { + const view = render( +
+
+ Hello + + world +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 10, top: 20, width: 30, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ + text: 'Hello world', + }) + }) + + it('rejects cross-block and pending text selections', () => { + const view = render( +
+
one
+ pending +
two
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sources = view.container.querySelectorAll('[data-text-interaction-source]') + const range = document.createRange() + range.setStart(sources[0]!.firstChild!, 0) + range.setEnd(sources[1]!.firstChild!, 3) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toBeNull() + }) + + it('keeps one visible newline for the Markdown hard-break DOM pair', () => { + const view = render( +
+
+ first
{'\n'}second +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + const candidate = readSelection(root, selection) + expect(candidate).toMatchObject({ + text: 'first\nsecond', + }) + }) + + it('projects Markdown soft line breaks as visible spaces', () => { + const view = render( +
+

{'hello\nworld'}

+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ text: 'hello world' }) + + const text = sourceRoot.querySelector('p')!.firstChild! + range.setStart(text, 6) + range.setEnd(text, 11) + expect(readSelection(root, selection)).toMatchObject({ text: 'world' }) + }) + + it('omits code banners while preserving preformatted line breaks', () => { + const view = render( +
+
+
typescript
+
{'const x = 1\nreturn x'}
+
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ text: 'const x = 1\nreturn x' }) + }) + + it('uses one synthetic newline when a hard break has no text-node newline', () => { + const view = render( +
+
+ first
second +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + const candidate = readSelection(root, selection) + expect(candidate).toMatchObject({ + text: 'first\nsecond', + }) + }) + + it('preserves visible boundaries between adjacent Markdown blocks', () => { + const view = render( +
+
+

first

second

+
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const range = document.createRange() + range.selectNodeContents(sourceRoot) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + const candidate = readSelection(root, selection) + expect(candidate).toMatchObject({ + text: 'first\nsecond', + }) + }) + + it('omits Markdown renderer whitespace between adjacent blocks', () => { + const view = render( +
+
+ +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const sourceRoot = view.container.querySelector('[data-text-interaction-source]')! + const paragraphs = sourceRoot.querySelectorAll('p') + expect(paragraphs[0]!.nextSibling?.textContent).toBe('\n') + const range = document.createRange() + range.setStart(paragraphs[0]!.firstChild!, 0) + range.setEnd(paragraphs[1]!.firstChild!, 'second'.length) + range.getBoundingClientRect = () => ( + { left: 50, top: 60, width: 20, height: 10 } as DOMRect + ) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ text: 'first\nsecond' }) + }) + + it('maps visible KaTeX text while omitting its duplicate MathML branch', () => { + const view = render( +
+
+ + hidden TeX source + + +
+
, + ) + const root = view.container.querySelector('[data-chat-flow]')! + const visible = view.container.querySelector('.katex-html')! + const range = document.createRange() + range.selectNodeContents(visible) + range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + expect(readSelection(root, selection)).toMatchObject({ text: 'x + 1' }) + }) + +}) + +describe('text source boundaries', () => { + function source(html: string) { + const root = document.createElement('div') + const source = document.createElement('div') + source.setAttribute('data-text-interaction-source', '') + source.innerHTML = html + root.append(source) + const range = document.createRange() + range.selectNodeContents(source) + return { root, source, range } + } + + it('preserves table cells, existing tabs, and inline code reference buttons', () => { + const fixture = source('
onetwo
three\tfour
five\tsix
') + expect(selectedText(fixture)).toBe('one\ttwo\nthree\tfour\nfive\tsix\nreference') + }) + + it('omits hidden content, comments, and empty text nodes', () => { + const fixture = source('spokenvisible') + fixture.source.append(document.createTextNode('')) + fixture.range.selectNodeContents(fixture.source) + expect(selectedText(fixture)).toBe('visible') + const hidden = fixture.source.querySelector('span')! + fixture.range.selectNodeContents(hidden) + expect(selectedText(fixture)).toBeNull() + fixture.range.setStart(hidden.firstChild!, 0) + fixture.range.setEnd(hidden.firstChild!, 4) + expect(selectedText(fixture)).toBeNull() + }) + + it('rejects whitespace-only and detached sources outside the area', () => { + const fixture = source(' ') + expect(selectedText(fixture)).toBeNull() + const selection = window.getSelection()! + selection.removeAllRanges() + expect(readTextSelection(fixture.root, null)).toBeNull() + expect(readTextSelection(fixture.root, selection)).toBeNull() + document.body.append(fixture.root) + try { + selection.addRange(fixture.range) + expect(readTextSelection(document.createElement('div'), selection)).toBeNull() + fixture.source.hidden = true + expect(readTextSelection(fixture.root, selection)).toBeNull() + } finally { fixture.root.remove(); selection.removeAllRanges() } + }) + + it('projects text in a document without a browsing context', () => { + const doc = document.implementation.createHTMLDocument() + const source = doc.createElement('div') + source.textContent = 'hello\nworld' + const range = doc.createRange() + range.selectNodeContents(source) + expect(selectedText({ source, range })).toBe('hello world') + }) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1606974f49..844653cbff 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -18,11 +18,11 @@ en: ef43c4f149195939 zh: 49fc6c83fd3a05ce /deepseek-ai-dsh-client-ui-conversation/sent-user-text: - en: 9912b286384c41d7 - zh: 3e994887c1948677 + en: 7871f9fe6bcc4459 + zh: 0d51565c600b3958 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: a7a0b235e1c5f2b0 - zh: ca24e6bf77ad7e1b + en: 457e642073206f7d + zh: 95ea47c204850314 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 84049c6848..fb3c5a44f8 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -59,6 +59,12 @@ Businesses extend `UserTextDataMap` and `UserTextFacts` on this package's `/clie Queue previews enable whitespace collapsing and document-edge trimming, retain emoji, and use CSS ellipsis without truncating the input. Editing and copying retain the original text; native attachments use their separate presentation. +[Message quotes](../../session/message-quotes/README.md) registers quote matching and rendering through the same UserText extension points. Each user-text container and draft editor owns an independent hover-card group, so its quote previews replace one another immediately. + +The existing semantic `DraftSnapshot` retains quote text and clipboard-coordinate spans beside references. Storage accepts earlier string and reference-only drafts; Session switching, Workspace transfer, and failed-send restoration preserve atomic quotes through the existing editor runtime. `inputActions.insertQuote(text)` inserts a nonblank selection only while the composer is in plain mode. Submission emits ordinary Markdown text; pending echoes, queue rows, steering rows, and durable messages use the same text matcher. + +`UiConversation.interactions` owns plugin registrations for selected-text and context-menu gestures. A registration supplies a synchronous handler whose ID selects its keyed Slot component; plugins use the Slot injection mechanism for rendering. Handlers call `open()` to display and call `preventDefault()` separately when replacing a browser default. Chat supplies DOM selections and local layout; original source data and Markdown offsets are not inferred from DOM positions. + ## Shell and standard props @@ -98,6 +104,8 @@ The width-handle indicator follows the pointer only during a captured drag; ordi The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), the claimed slash command is an atomic leading chip that carries its claim, folder text references carry the folder glyph as an icon prefix, and the draft's text and semantic references are persisted by the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews use the [UserText Factory](#sent-user-text) (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +In narrow browser settings, the busy-state Enter preference uses a smaller label gutter. Windows and macOS desktop windows retain the standard spacing. + Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads. Clicking unused space inside an editable composer preserves its current caret or selection when focused, or restores its previous caret or selection when unfocused. Text selection, scrollbars, controls, and embedded overlays keep their own pointer behavior. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 0884a9cd39..8cf77157c3 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -59,6 +59,12 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CSS 省略而不截断输入。编辑和复制保留原文;原生附件使用独立的展示路径。 +[Message quotes](../../session/message-quotes/README.zh.md) 通过相同的 UserText 扩展点注册引用匹配与渲染。 每个用户文本容器和草稿编辑器各自拥有独立的悬停卡片分组,其中的 quote 原文弹层会立即互相替换。 + +现有语义 `DraftSnapshot` 在 references 旁保留 quote 文本及 clipboard 坐标范围。存储接受较早的字符串和仅含 reference 的草稿;Session 切换、Workspace 转移以及发送失败恢复通过现有编辑器 runtime 保留原子 quote。`inputActions.insertQuote(text)` 仅在输入框处于 plain 模式时插入非空选区。提交产出普通 Markdown 文本;待发送回显、队列行、steering 行和持久消息使用同一文本匹配器。 + +`UiConversation.interactions` 拥有插件对文本选择和右键手势的注册。每个注册项提供同步处理函数,其 ID 选择对应的 keyed Slot 组件;插件通过 Slot injection 机制渲染。处理函数调用 `open()` 展示,需要替代浏览器默认行为时单独调用 `preventDefault()`。Chat 提供 DOM 选区和区域内布局;机制不从 DOM 位置推断原始来源数据或 Markdown 偏移。 + ## Shell 与标准 props @@ -98,6 +104,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 是携带认领的行首原子 chip,文件夹文本引用以图标前缀携带文件夹图形,草稿文字和引用语义字段由逐 Session Conversation store 持久化。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览使用 [UserText Factory](#sent-user-text)(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +窄屏浏览器设置中,繁忙时 Enter 偏好的标签与控件间距缩小;Windows 和 macOS 桌面窗口保留标准间距。 + 普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。 点击可编辑输入框内的留白时,已聚焦的输入框保留当前光标或选区,未聚焦的输入框恢复上次的光标或选区。文本选择、滚动条、控件和内嵌浮层保持各自的指针交互行为。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 11a24a79f7..09c12d692d 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -96,7 +96,8 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", - "@deepseek-ai/dsh-client-product-analytics": "workspace:*" + "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-message-quotes": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-conversation/src/client/contract/draft-editor.ts b/packages/client/ui-conversation/src/client/contract/draft-editor.ts index 7d523c97d8..b305bb3a3c 100644 --- a/packages/client/ui-conversation/src/client/contract/draft-editor.ts +++ b/packages/client/ui-conversation/src/client/contract/draft-editor.ts @@ -22,6 +22,14 @@ export interface ReferenceInsert { /** Persisted reference data without the current editor's occurrence identity. */ export type DraftReference = Omit +/** One atomic quote in clipboard-text coordinates. */ +export interface DraftQuote { + readonly offset: number + readonly length: number + /** Selected text before Markdown quote prefixes are added. */ + readonly text: string +} + /** * Persisted command chip opening a draft. The claim itself (its submit * closure) is not persisted; the input rebuilds it from the token through the @@ -41,6 +49,7 @@ export interface DraftSnapshot { readonly text: string readonly references: readonly DraftReference[] readonly command?: DraftCommand + readonly quotes?: readonly DraftQuote[] } /** Plain initial text or an existing document with explicit reference identities. */ diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index c01ed5f99f..a47e044b6c 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -182,6 +182,9 @@ export interface InputTarget { /** Per-session input facade owned by the conversation wiring layer. */ export interface SessionInput extends InputTarget { + /** Insert selected transcript text as an atomic quote. @param text - nonblank selection. @returns whether editing accepted it. */ + insertQuote(text: string): boolean + /** Replace the whole draft with plain text, removing inline reference chips. */ setDraft(text: string): void /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ @@ -248,11 +251,13 @@ export type DraftInitializationResult = 'applied' | 'preserved' | 'blocked' /** * The public input action face provided to every session-scope slot - * component: stable-identity void callbacks, mirroring the + * component: stable-identity callbacks, mirroring the * useStore+actions convention. Command-style handles (arbitrate/space/ * paste/…) stay InputBar-private and never ride this face. */ export interface InputActions { + /** Insert one nonblank transcript quote. @param text - selected text. @returns whether editing accepted it. */ + insertQuote(text: string): boolean /** @returns a revision-guarded copy of the current editor selection. */ captureInsertion(): TokenSpan /** diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index 6d7359e71d..daddf0aac7 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -41,6 +41,8 @@ export type AssistantBlock = /** A finalized user message. */ export interface UserMessageNode { kind: 'user' + /** Durable identity, absent only in synthetic projections. */ + messageId?: MessageId seq: number /** Unix epoch ms from the source session event. */ time: number diff --git a/packages/client/ui-conversation/src/client/contract/text-interaction.ts b/packages/client/ui-conversation/src/client/contract/text-interaction.ts new file mode 100644 index 0000000000..7f1707d61b --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/text-interaction.ts @@ -0,0 +1,35 @@ +/** Conversation text gestures and plugin-owned action surfaces. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Browser gesture offered to text interaction consumers. */ +export type TextInteractionTrigger = 'selection' | 'contextmenu' + +/** Synchronous browser gesture; reading text does not imply source-data offsets. */ +export interface TextInteractionGesture { + readonly trigger: TextInteractionTrigger + readonly hasSelection: boolean + readonly readText: () => string | null + /** Cancel the originating browser event during dispatch only. */ + readonly preventDefault: () => void +} + +/** Scoped gesture offered to one registered plugin. */ +export interface TextInteractionRequest extends TextInteractionGesture { + readonly sessionId: SessionId + /** Select this plugin's keyed surface for the current gesture. */ + readonly open: () => void +} + +/** One consumer whose ID also names its keyed Slot renderer. */ +export interface TextInteractionDefinition { + readonly id: string + readonly onInteraction: (request: TextInteractionRequest) => void +} + +/** Area-local actions provided to the currently selected plugin component. */ +export interface TextInteractionSurface { + readonly trigger: TextInteractionTrigger + readonly dismiss: () => void + readonly readText: () => string | null + readonly clearSelection: () => void +} diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index b6cfcd6318..9b3ed5f5b8 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -24,6 +24,7 @@ import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' import { ConversationGroupRegistry } from './group-registry.ts' +import { ConversationInteractionRegistry } from './interaction-registry.ts' import { UserTextRegistry } from './user-text-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ @@ -193,6 +194,8 @@ export class UiConversation extends Service { readonly groups: ConversationGroupRegistry /** Pure text rules and parser snapshots, independent of durable Session assembly. */ readonly userText: UserTextRegistry + /** Plugin-owned handlers for selection and context-menu gestures. */ + readonly interactions: ConversationInteractionRegistry private readonly bindings = new WeakMapWithValues() private readonly images: HistoricalImageCache @@ -206,6 +209,7 @@ export class UiConversation extends Service { this.views = new ConversationViewRegistry(ctx) this.groups = new ConversationGroupRegistry(ctx, this.views) this.userText = new UserTextRegistry(ctx) + this.interactions = new ConversationInteractionRegistry(ctx) this.images = new HistoricalImageCache(ctx, sessions) const rebuild = (): void => { for (const record of this.bindings.values) record.binding.rebuild() diff --git a/packages/client/ui-conversation/src/client/conversation/interaction-registry.ts b/packages/client/ui-conversation/src/client/conversation/interaction-registry.ts new file mode 100644 index 0000000000..bd909da841 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/interaction-registry.ts @@ -0,0 +1,55 @@ +/** Plugin-owned text gesture handlers, scoped by the dispatching conversation. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TextInteractionDefinition, TextInteractionGesture } from '../contract/text-interaction.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' + +/** Effect-owned registrations; active surfaces belong to individual Chat views. */ +export class ConversationInteractionRegistry extends ConversationDefinitionRegistry { + private ids: readonly string[] = [] + + /** + * Register a synchronous handler and its keyed renderer identity. + * @param definition - uniquely named plugin handler. + * @returns idempotent disposer tied to the registering plugin's lifetime. + */ + register(definition: TextInteractionDefinition): () => void { + if (definition.id === '') throw new Error('text interaction ID must not be empty') + return this.registerDefinition(definition.id, definition, + `Duplicate text interaction: ${definition.id}`, `uiConversation.interactions.register(${JSON.stringify(definition.id)})`) + } + + /** + * Offer a gesture until one handler opens its surface; opening never prevents browser defaults. + * @param sessionId - conversation where the gesture occurred. + * @param gesture - synchronous access to the browser event and DOM text. + * @returns selected renderer ID, or null when no plugin opens a surface. + */ + dispatch(sessionId: SessionId, gesture: TextInteractionGesture): string | null { + for (const definition of this.entries()) { + const state = { opened: false } + let dispatching = true + try { + definition.onInteraction({ + ...gesture, sessionId, + open: () => { if (dispatching) state.opened = true }, + preventDefault: () => { if (dispatching) gesture.preventDefault() }, + }) + } finally { dispatching = false } + if (state.opened) return definition.id + } + return null + } + + /** + * Observe registration changes so Chat views can dismiss stale surfaces. + * @returns stable IDs until the registry changes. + */ + getSnapshot(): readonly string[] { + return this.ids + } + + protected override refresh(): void { + this.ids = [...this.definitions.keys()] + super.refresh() + } +} diff --git a/packages/client/ui-conversation/src/client/draft.ts b/packages/client/ui-conversation/src/client/draft.ts index cc1c814f43..e4cee855b2 100644 --- a/packages/client/ui-conversation/src/client/draft.ts +++ b/packages/client/ui-conversation/src/client/draft.ts @@ -1,6 +1,7 @@ /** Semantic draft snapshots shared by storage, programmatic input, and editor restoration. */ import { z } from 'zod' -import type { DraftInput, DraftReference, DraftSnapshot, Occurrence } from './contract/draft-editor.ts' +import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { DraftInput, DraftQuote, DraftReference, DraftSnapshot, Occurrence } from './contract/draft-editor.ts' const referenceSchema = z.object({ source: z.string(), @@ -17,10 +18,17 @@ const commandSchema = z.object({ token: z.string(), }) +const quoteSchema = z.object({ + offset: z.number().int().nonnegative(), + length: z.number().int().positive(), + text: z.string().refine(text => text.trim() !== ''), +}) + const snapshotSchema = z.object({ text: z.string(), references: z.array(referenceSchema), command: commandSchema.optional(), + quotes: z.array(quoteSchema).optional(), }).refine((draft) => { const spelling = draft.command?.token.trimEnd() if (spelling !== undefined && (!/^\/\S/u.test(spelling) || !draft.text.startsWith(spelling))) return false @@ -30,6 +38,16 @@ const snapshotSchema = z.object({ end = reference.offset + reference.length if (end > draft.text.length || draft.text.slice(reference.offset, end) !== reference.clipboardText) return false } + for (const quote of draft.quotes ?? []) { + if (quote.length !== formatQuoteText(quote.text).length + || draft.text.slice(quote.offset, quote.offset + quote.length) !== formatQuoteText(quote.text)) return false + } + end = spelling?.length ?? 0 + for (const span of [...draft.references, ...draft.quotes ?? []].sort((a, b) => a.offset - b.offset)) { + if (span.offset < end) return false + end = span.offset + span.length + if (end > draft.text.length) return false + } return true }, 'Draft references must identify ordered, non-overlapping text spans after the leading command token') @@ -44,6 +62,7 @@ export function resolveDraftInput(input: DraftInput): DraftSnapshot { text: input.text, references: input.references.map(copyReference), ...(input.command === undefined ? {} : { command: { token: input.command.token } }), + ...(input.quotes === undefined ? {} : { quotes: input.quotes.map(quote => ({ ...quote })) }), } } @@ -58,6 +77,7 @@ export function parseStoredDraft(value: unknown): DraftSnapshot | undefined { if (!parsed.success) return undefined return { text: parsed.data.text, + ...parsed.data.quotes === undefined ? {} : { quotes: parsed.data.quotes }, references: parsed.data.references.map(({ appearance, invalid, ...reference }) => ({ ...reference, ...(appearance === undefined ? {} : { appearance }), @@ -72,13 +92,17 @@ export function parseStoredDraft(value: unknown): DraftSnapshot | undefined { * @param text - current clipboard projection. * @param occurrences - matching reference projections from the same document revision. * @param commandToken - claim token of the command chip opening the document, if any. + * @param quotes - atomic quotes from the same document revision. * @returns an editor-independent snapshot. */ -export function snapshotDraft(text: string, occurrences: readonly Occurrence[], commandToken?: string): DraftSnapshot { +export function snapshotDraft( + text: string, occurrences: readonly Occurrence[], commandToken?: string, quotes: readonly DraftQuote[] = [], +): DraftSnapshot { return { text, references: occurrences.map(copyReference), ...(commandToken === undefined ? {} : { command: { token: commandToken } }), + ...(quotes.length === 0 ? {} : { quotes: quotes.map(quote => ({ ...quote })) }), } } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 08a7432e1e..d4f06accc5 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -102,7 +102,7 @@ export type { InputState, InsertReferenceRequest, InsertTextRequest, PickOutcome, SessionInput, SessionInputResolver, SubmitAttachment, SubmitOutcome, } from './contract/input.ts' -export type { ArbitrateKey, ArbitrateOutcome, DraftInput, DraftReference, DraftSnapshot, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts' +export type { ArbitrateKey, ArbitrateOutcome, DraftInput, DraftQuote, DraftReference, DraftSnapshot, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts' export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' declare module '@deepseek-ai/cordis' { @@ -113,3 +113,6 @@ declare module '@deepseek-ai/cordis' { uiConversation: import('./conversation/assembly.ts').UiConversation } } + +export type { TextInteractionDefinition, TextInteractionGesture, TextInteractionRequest, TextInteractionSurface, TextInteractionTrigger } from './contract/text-interaction.ts' +export type { ConversationInteractionRegistry } from './conversation/interaction-registry.ts' diff --git a/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx b/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx index d03ff6232c..a51ef34160 100644 --- a/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx +++ b/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx @@ -2,6 +2,7 @@ import type { CSSProperties, KeyboardEventHandler, ReactNode, RefObject } from 'react' import type { LexicalEditor } from 'lexical' import clsx from 'clsx' +import { HoverCardGroup } from '@deepseek-ai/dsh-client-ui-primitives' import type { InputState } from '../../contract/input.ts' import { ComposerContentEditable } from './ComposerContentEditable.tsx' import { DecoratorPortals } from './DecoratorPortals.tsx' @@ -34,7 +35,7 @@ export function DraftEditor({ }: DraftEditorProps): ReactNode { return (
-
+ )} -
+
) } diff --git a/packages/client/ui-conversation/src/client/input/editor/QuoteNode.tsx b/packages/client/ui-conversation/src/client/input/editor/QuoteNode.tsx new file mode 100644 index 0000000000..ecd42af89d --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/QuoteNode.tsx @@ -0,0 +1,152 @@ +/** Atomic composer quote node and whole-node removal command. */ + +import type { JSX } from 'react' +import { QuotePreview } from '@deepseek-ai/dsh-client-ui-primitives' +import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { + EditorConfig, LexicalEditor, LexicalNode, NodeKey, SerializedLexicalNode, Spread, +} from 'lexical' +import { createCommand, DecoratorNode } from 'lexical' + +/** Localized non-persisted labels applied by the owning input shell. */ +export interface QuoteNodeLabels { + readonly remove: string +} + +const EMPTY_LABELS: QuoteNodeLabels = { remove: '' } + +/** JSON form used only by Lexical's editor-state APIs, not browser persistence. */ +export type SerializedQuoteNode = Spread<{ + text: string +}, SerializedLexicalNode> + +/** Remove one quote occurrence by its Lexical identity. */ +export const REMOVE_QUOTE_COMMAND = createCommand('REMOVE_QUOTE_COMMAND') + +/** Pure React face of one quote node. */ +export function QuoteNodeView({ editor, nodeKey, text, labels }: { + readonly editor: LexicalEditor + readonly nodeKey: NodeKey + readonly text: string + readonly labels: QuoteNodeLabels +}) { + return ( + { editor.dispatchCommand(REMOVE_QUOTE_COMMAND, nodeKey) }, + }} + /> + ) +} + +/** One selected transcript range represented as an atomic inline decorator. */ +export class QuoteNode extends DecoratorNode { + __text: string + __labels: QuoteNodeLabels + + /** Lexical node registry type tag. */ + static override getType(): string { + return 'quote' + } + + /** @param node - node to clone. @returns a copy carrying the same NodeKey. */ + static override clone(node: QuoteNode): QuoteNode { + return new QuoteNode( + node.__text, + node.__labels, + node.__key, + ) + } + + /** @param json - serialized editor node. @returns a fresh node with default runtime labels. */ + static override importJSON(json: SerializedQuoteNode): QuoteNode { + return new QuoteNode(json.text) + } + + /** + * @param text - selected text. + * @param labels - runtime-only localized labels. + * @param key - Lexical clone-path key; absent for fresh nodes. + */ + constructor( + text: string, + labels: QuoteNodeLabels = EMPTY_LABELS, + key?: NodeKey, + ) { + super(key) + this.__text = text + this.__labels = labels + } + + /** Serialize the quote data without its runtime presentation state. */ + override exportJSON(): SerializedQuoteNode { + return { + ...super.exportJSON(), + type: 'quote', + version: 1, + text: this.__text, + } + } + + /** Mount one non-editable inline host. */ + override createDOM(_config: EditorConfig): HTMLElement { + const element = document.createElement('span') + element.setAttribute('data-composer-quote', '') + element.setAttribute('contenteditable', 'false') + return element + } + + /** Updates reuse the existing host element. */ + override updateDOM(): boolean { + return false + } + + /** Quotes flow with draft text and wrap at their own text boundary. */ + override isInline(): boolean { + return true + } + + /** Arrow movement crosses the quote without entering a NodeSelection. */ + override isKeyboardSelectable(): boolean { + return false + } + + /** Clipboard text is the exact model-visible Markdown blockquote. */ + override getTextContent(): string { + return formatQuoteText(this.__text) + } + + /** Selected visible quote text. */ + getQuoteText(): string { + return this.getLatest().__text + } + + /** Update the localized remove label without changing persisted quote data. */ + setLabels(labels: QuoteNodeLabels): void { + if (this.getLatest().__labels.remove !== labels.remove) this.getWritable().__labels = labels + } + + /** Render the quote body and its independent close control. */ + override decorate(editor: LexicalEditor): JSX.Element { + return ( + + ) + } +} + +/** @param text - selected quote text. @param labels - runtime labels. @returns a fresh node. */ +export function $createQuoteNode(text: string, labels: QuoteNodeLabels): QuoteNode { + return new QuoteNode(text, labels) +} + +/** @param node - possible Lexical node. @returns whether it is a QuoteNode. */ +export function $isQuoteNode(node: LexicalNode | null | undefined): node is QuoteNode { + return node instanceof QuoteNode +} diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index b4fe576e19..a2573fdefc 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -16,11 +16,14 @@ */ import type { LexicalEditor } from 'lexical' import { + $getAdjacentNode, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, + KEY_DOWN_COMMAND, MOVE_TO_END, COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND, PASTE_COMMAND, } from 'lexical' import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome } from '../../contract/draft-editor.ts' +import { $isQuoteNode } from './QuoteNode.tsx' /** The bar-supplied behavior behind each intercepted gesture. */ export interface ComposerKeymapHandlers { @@ -51,6 +54,118 @@ function isComposingEvent(event: KeyboardEvent, recentlyComposing: () => boolean return event.isComposing || event.keyCode === 229 || recentlyComposing() } +function isApplePlatform(): boolean { + return typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform) +} + +/** Whether the browser caret touches one side of an atomic quote element. */ +function domCaretTouches(element: HTMLElement, backward: boolean): boolean { + const selection = element.ownerDocument.defaultView?.getSelection() + if (selection === null || selection === undefined || !selection.isCollapsed) return false + const focus = selection.focusNode + if (focus === null) return false + const parent = element.parentNode + if (parent === null) return false + const offset = selection.focusOffset + if (focus === parent) { + return focus.childNodes.item(backward ? offset - 1 : offset) === element + } + if (focus.nodeType === Node.TEXT_NODE) { + const size = focus.textContent?.length ?? 0 + if (backward ? offset !== 0 : offset !== size) return false + } else if (backward ? offset !== 0 : offset !== focus.childNodes.length) { + return false + } + let boundary = focus + while (boundary.parentNode !== null && boundary.parentNode !== parent) { + if ((backward ? boundary.previousSibling : boundary.nextSibling) !== null) return false + boundary = boundary.parentNode + } + return boundary.parentNode === parent + && (backward ? boundary.previousSibling : boundary.nextSibling) === element +} + +/** Find an atomic quote immediately beside both editor and browser carets. */ +function adjacentQuote(editor: LexicalEditor, backward: boolean) { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null + const adjacent = $getAdjacentNode(selection.focus, backward) + if (!$isQuoteNode(adjacent)) return null + const element = editor.getElementByKey(adjacent.getKey()) + return element !== null && domCaretTouches(element, backward) ? adjacent : null +} + +function lineLeadingQuoteBeforeSelection(editor: LexicalEditor) { + let quote = adjacentQuote(editor, true) + if (quote === null) return null + let previous = quote.getPreviousSibling() + while (previous !== null) { + if ($isLineBreakNode(previous)) return quote + if ($isQuoteNode(previous)) { + quote = previous + previous = previous.getPreviousSibling() + continue + } + if (!$isTextNode(previous)) return null + const text = previous.getTextContent() + const lastBreak = text.lastIndexOf('\n') + if (lastBreak >= 0) return text.slice(lastBreak + 1) === '' ? quote : null + if (text.trim() !== '') return null + const precedingQuote = previous.getPreviousSibling() + if (!$isQuoteNode(precedingQuote)) return null + quote = precedingQuote + previous = quote.getPreviousSibling() + } + return quote +} + +/** Put the browser caret before an adjacent line-leading quote. */ +function moveBeforeQuote(editor: LexicalEditor, event: KeyboardEvent): boolean { + if (event.shiftKey) return false + const quote = lineLeadingQuoteBeforeSelection(editor) + if (quote === null) return false + const element = editor.getElementByKey(quote.getKey()) + if (element === null) return false + const view = element.ownerDocument.defaultView + if (view === null) return false + const domSelection = view.getSelection() + if (domSelection === null) return false + const range = element.ownerDocument.createRange() + range.setStartBefore(element) + range.collapse(true) + domSelection.removeAllRanges() + domSelection.addRange(range) + event.preventDefault() + return true +} + +/** Move from immediately before a quote to the end of its hard line. */ +function movePastQuote(editor: LexicalEditor, event: KeyboardEvent): boolean { + if (!isApplePlatform() || event.key !== 'ArrowRight' || !event.metaKey + || event.ctrlKey || event.altKey || event.shiftKey) return false + const quote = adjacentQuote(editor, false) + if (quote === null) return false + const block = quote.getParent() + if (!$isElementNode(block)) return false + let lineEnd = quote.getIndexWithinParent() + 1 + while (lineEnd < block.getChildrenSize()) { + const child = block.getChildAtIndex(lineEnd) + if ($isLineBreakNode(child)) break + if ($isTextNode(child)) { + const hardBreak = child.getTextContent().indexOf('\n') + if (hardBreak >= 0) { + event.preventDefault() + child.select(hardBreak, hardBreak) + return true + } + } + lineEnd += 1 + } + event.preventDefault() + block.select(lineEnd, lineEnd) + return true +} + /** * Register the composer keymap on one editor. * @param editor - the shell-owned editor. @@ -101,6 +216,13 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer root?.addEventListener('compositionend', onCompositionEnd) syncComposition() }), + editor.registerCommand(KEY_DOWN_COMMAND, (event) => { + if (!isApplePlatform() || !event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false + const key = event.key.toLowerCase() + if (key === 'a') return moveBeforeQuote(editor, event) + return false + }, COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(MOVE_TO_END, event => movePastQuote(editor, event), COMMAND_PRIORITY_CRITICAL), editor.registerUpdateListener(syncComposition), editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL), diff --git a/packages/client/ui-conversation/src/client/input/editor/projection.ts b/packages/client/ui-conversation/src/client/input/editor/projection.ts index c48c97ad8a..dacc465d3b 100644 --- a/packages/client/ui-conversation/src/client/input/editor/projection.ts +++ b/packages/client/ui-conversation/src/client/input/editor/projection.ts @@ -11,8 +11,9 @@ import type { ElementNode, LexicalNode, NodeKey, Point } from 'lexical' import { $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, } from 'lexical' -import type { Occurrence } from '../../contract/draft-editor.ts' +import type { DraftQuote, Occurrence } from '../../contract/draft-editor.ts' import type { CommandClaim } from '../../contract/input.ts' +import { $isQuoteNode } from './QuoteNode.tsx' import { $isReferenceChipNode } from './chip-node.tsx' import { $isCommandChipNode } from './command-chip-node.tsx' @@ -81,7 +82,7 @@ export function $composerLayout(): ComposerLayout { const kids = element.getChildren() children.set(element.getKey(), kids.map(kid => kid.getKey())) for (const kid of kids) { - if ($isReferenceChipNode(kid) || $isCommandChipNode(kid)) { + if ($isReferenceChipNode(kid) || $isCommandChipNode(kid) || $isQuoteNode(kid)) { pushLeaf('chip', kid, ATOMIC_CHAR, kid.getTextContent()) } else if ($isTextNode(kid)) { const text = kid.getTextContent() @@ -160,6 +161,8 @@ export interface EditorProjection { readonly clipboardText: string /** InputState-compatible occurrence view (clipboardText coordinates). */ readonly occurrences: readonly Occurrence[] + /** Atomic quotes in clipboard coordinates. */ + readonly quotes: readonly DraftQuote[] /** Range selection in detect coordinates (ordered); null while absent or non-range. */ readonly selection: { readonly start: number; readonly end: number } | null /** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */ @@ -200,8 +203,14 @@ export function $detectOffsetOfPoint(layout: ComposerLayout, point: Point): numb export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjection { const layout = $composerLayout() const occurrences: Occurrence[] = [] + const quotes: DraftQuote[] = [] for (const segment of layout.segments) { - if (segment.kind !== 'chip' || !$isReferenceChipNode(segment.node)) continue + if (segment.kind !== 'chip') continue + if ($isQuoteNode(segment.node)) { + quotes.push({ offset: segment.clipboardStart, length: segment.clipboardLength, text: segment.node.getQuoteText() }) + continue + } + if (!$isReferenceChipNode(segment.node)) continue const chip = segment.node occurrences.push({ occurrenceId: idOf(chip.getKey()), @@ -229,6 +238,7 @@ export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjecti detectText: layout.detectText, clipboardText: layout.clipboardText, occurrences, + quotes, selection: range, caret: range !== null && range.start === range.end ? range.start : null, command: $isCommandChipNode(lead) ? lead.getClaim() : undefined, diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index 31116db781..698ef7bda6 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -2,15 +2,19 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { LexicalEditor, NodeKey } from 'lexical' import { - $addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, - BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_CRITICAL, createEditor, HISTORY_MERGE_TAG, PASTE_TAG, - RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG, + $addUpdateTag, $createParagraphNode, $createTextNode, $getAdjacentNode, $isTextNode, + $getRoot, $getSelection, $getNodeByKey, $isRangeSelection, + BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_BEFORE_CRITICAL, COMMAND_PRIORITY_CRITICAL, createEditor, + HISTORY_MERGE_TAG, PASTE_TAG, + RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, HISTORY_PUSH_TAG, } from 'lexical' import { registerPlainText } from '@lexical/plain-text' import { createEmptyHistoryState, registerHistory } from '@lexical/history' import { mergeRegister } from '@lexical/utils' -import type { DraftReference, ReferenceInsert } from '../../contract/draft-editor.ts' +import type { DraftQuote, DraftReference, ReferenceInsert } from '../../contract/draft-editor.ts' import type { CommandClaim } from '../../contract/input.ts' +import { QuoteNode, $createQuoteNode, $isQuoteNode, REMOVE_QUOTE_COMMAND } from './QuoteNode.tsx' +import type { QuoteNodeLabels } from './QuoteNode.tsx' import { registerReferenceActivation } from './reference-activation.ts' import { ReferenceChipNode, $createReferenceChipNode } from './chip-node.tsx' import { CommandChipNode, $createCommandChipNode, registerCommandChipSeat } from './command-chip-node.tsx' @@ -26,6 +30,7 @@ type Lexicon = ReadonlyMap<'/' | '@', readonly string[]> /** Model callbacks read at the same editor registration and update points. */ interface DraftEditorRuntimeDeps { readonly onUpdate: () => void + readonly quoteLabels: () => QuoteNodeLabels readonly openReference: (source: string | undefined, reference: Pick) => boolean readonly lexicon: () => Lexicon readonly resolveLexicon: () => ObservableSnapshot | undefined @@ -62,7 +67,7 @@ export class DraftEditorRuntime { /** The editor bound by the Composer's contenteditable host. */ readonly editor: LexicalEditor private projected: EditorProjection = { - detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null, command: undefined, + detectText: '', clipboardText: '', occurrences: [], quotes: [], selection: null, caret: null, command: undefined, } /** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */ private readonly occurrenceIds = new Map() @@ -75,7 +80,7 @@ export class DraftEditorRuntime { constructor(private readonly deps: DraftEditorRuntimeDeps) { this.editor = createEditor({ namespace: 'dsh-composer', - nodes: [ReferenceChipNode, CommandChipNode, TextRefNode], + nodes: [ReferenceChipNode, CommandChipNode, TextRefNode, QuoteNode], onError: (error) => { throw error }, }) } @@ -95,6 +100,10 @@ export class DraftEditorRuntime { } const unregister = mergeRegister( registerPlainText(this.editor), + this.editor.registerNodeTransform(QuoteNode, (node) => { node.setLabels(this.deps.quoteLabels()) }), + this.editor.registerCommand(REMOVE_QUOTE_COMMAND, key => this.removeQuote(key), COMMAND_PRIORITY_CRITICAL), + this.editor.registerCommand(KEY_BACKSPACE_COMMAND, event => this.deleteAdjacentQuote(event, true), COMMAND_PRIORITY_BEFORE_CRITICAL), + this.editor.registerCommand(KEY_DELETE_COMMAND, event => this.deleteAdjacentQuote(event, false), COMMAND_PRIORITY_BEFORE_CRITICAL), this.editor.registerCommand(BLUR_COMMAND, () => { // Finish this batch before an explicit focus can restore its updated selection. this.editor.update(preserveExternalSelection, { discrete: true }) @@ -186,7 +195,7 @@ export class DraftEditorRuntime { */ setDraft(text: string): void { const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '') - if (clean === this.projection.clipboardText && this.projection.occurrences.length === 0) return + if (clean === this.projection.clipboardText && this.projection.occurrences.length === 0 && this.projection.quotes.length === 0) return this.editor.update(() => { $getRoot().clear() $appendParagraphs(clean) @@ -332,6 +341,60 @@ export class DraftEditorRuntime { return applied } + /** + * Insert one quote after the retained draft selection as an independent undo edit. + * @param text - nonblank selected transcript text. + * @param labels - current localized editor controls. + * @returns whether the insertion position accepted the node. + */ + insertQuote(text: string, labels: QuoteNodeLabels): boolean { + let applied = false + this.applyEdit(() => { + const projection = $projectComposer(key => this.occurrenceIdOf(key)) + const at = projection.selection?.end ?? projection.detectText.length + applied = $replaceDetectSpanWithNodes({ start: at, end: at }, [$createQuoteNode(text, labels), $createTextNode(' ')]) + }, PASTE_TAG) + return applied + } + + /** Remove one quote node and leave the editor caret at its former position. */ + private removeQuote(key: NodeKey): boolean { + const node = $getNodeByKey(key) + if (!$isQuoteNode(node)) return false + const parent = node.getParent() + const offset = node.getIndexWithinParent() + const separator = node.getNextSibling() + $addUpdateTag(HISTORY_PUSH_TAG) + if ($isTextNode(separator) && separator.getTextContent().startsWith(' ')) { + const remaining = separator.getTextContent().slice(1) + if (remaining === '') separator.remove() + else separator.setTextContent(remaining) + } + node.remove() + const caret = Math.min(offset, parent?.getChildrenSize() ?? 0) + parent?.select(caret, caret) + queueMicrotask(() => { this.editor.getRootElement()?.focus({ preventScroll: true }) }) + return true + } + + /** Delete the QuoteNode immediately beside one collapsed caret. */ + private deleteAdjacentQuote(event: KeyboardEvent, backward: boolean): boolean { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false + let adjacent = $getAdjacentNode(selection.anchor, backward) + if (!$isQuoteNode(adjacent) && backward && selection.anchor.type === 'text') { + const anchor = selection.anchor.getNode() + if ($isTextNode(anchor) + && selection.anchor.offset === 1 + && anchor.getTextContent().startsWith(' ')) { + adjacent = anchor.getPreviousSibling() + } + } + if (!$isQuoteNode(adjacent)) return false + event.preventDefault() + return this.removeQuote(adjacent.getKey()) + } + /** * Clear committed content using the model's suffix decision inside the editor update. * @param prefixLength - returns the clipboard-prefix length to remove, or null to clear the root. @@ -356,8 +419,10 @@ export class DraftEditorRuntime { * Import semantic content or a model-selected failure snapshot with fresh reference nodes. * @param draft - clipboard text. * @param occurrences - reference occurrences in clipboard order. + * @param quotes - atomic quotes in clipboard order. + * @param labels - localized quote controls. */ - restoreDraft(draft: string, occurrences: readonly DraftReference[]): void { + restoreDraft(draft: string, occurrences: readonly DraftReference[], quotes: readonly DraftQuote[] = [], labels: QuoteNodeLabels = { remove: '' }): void { this.editor.update(() => { const root = $getRoot() root.clear() @@ -375,15 +440,26 @@ export class DraftEditorRuntime { } } let cursor = 0 - for (const occurrence of occurrences) { + const spans = [ + ...occurrences.map(reference => ({ kind: 'reference' as const, value: reference })), + ...quotes.map(quote => ({ kind: 'quote' as const, value: quote })), + ].sort((a, b) => a.value.offset - b.value.offset) + for (const span of spans) { + const occurrence = span.value appendText(draft.slice(cursor, occurrence.offset)) + if (span.kind === 'quote') { + paragraph.append($createQuoteNode(span.value.text, labels)) + cursor = occurrence.offset + occurrence.length + continue + } + const reference = span.value paragraph.append(new ReferenceChipNode({ - source: occurrence.source, - ref: occurrence.ref, - label: occurrence.label, - ...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }), - clipboardText: occurrence.clipboardText, - }, occurrence.invalid === true)) + source: reference.source, + ref: reference.ref, + label: reference.label, + ...(reference.appearance === undefined ? {} : { appearance: reference.appearance }), + clipboardText: reference.clipboardText, + }, reference.invalid === true)) cursor = occurrence.offset + occurrence.length } appendText(draft.slice(cursor)) diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 1965b30d12..b62228f0ca 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -20,9 +20,10 @@ import type { SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome, } from '../contract/input.ts' import type { - ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, DraftInput, DraftSnapshot, Occurrence, ReferenceInsert, TokenSpan, + ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, DraftQuote, DraftInput, DraftSnapshot, Occurrence, ReferenceInsert, TokenSpan, } from '../contract/draft-editor.ts' import type { InputSubmitMode, MessageSubmission, MessageSubmissionState } from '../contract/composer-submission.ts' +import { serializeDraftPrompt } from './prompt.ts' import { SubmitMachine } from './machine.ts' import { resolveDraftInput, snapshotDraft } from '../draft.ts' import { DraftEditorRuntime } from './editor/runtime.ts' @@ -63,6 +64,10 @@ export interface SessionInputDeps { mode: InputSubmitMode, signal: AbortSignal, ): Promise + /** Live Session and composer-block policy for transcript insertion. */ + quoteInsertionAllowed?: () => boolean + /** Current localized quote removal copy. */ + quoteRemoveLabel?: () => string /** Command-plane attachment plumbing (the hub owns the conversation face and the copy). */ commandAttachments: { /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */ @@ -87,6 +92,10 @@ function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' { function projectionContentChanged(prev: EditorProjection, next: EditorProjection): boolean { if (prev.clipboardText !== next.clipboardText || prev.detectText !== next.detectText) return true if (prev.command !== next.command) return true + if (prev.quotes.length !== next.quotes.length || prev.quotes.some((quote, i) => { + const old = next.quotes[i] + return old?.text !== quote.text || old.offset !== quote.offset + })) return true if (prev.occurrences.length !== next.occurrences.length) return true return next.occurrences.some((occ, i) => { const old = prev.occurrences[i] @@ -103,6 +112,7 @@ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() interface DetachedDraft { readonly draft: string readonly occurrences: readonly Occurrence[] + readonly quotes: readonly DraftQuote[] readonly attachmentIds: readonly DraftAttachmentId[] } @@ -122,6 +132,7 @@ export class SessionInputShell implements SessionInput { } /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { + insertQuote: text => this.insertQuote(text), captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }), insertText: (text, span) => { if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting' || this.disposed) return false @@ -175,6 +186,7 @@ export class SessionInputShell implements SessionInput { constructor(private readonly deps: SessionInputDeps) { this.draftEditor = new DraftEditorRuntime({ onUpdate: () => { this.onEditorUpdate() }, + quoteLabels: () => ({ remove: this.deps.quoteRemoveLabel?.() ?? '' }), openReference: (source, reference) => this.deps.inputTriggers?.()?.openReference(source, reference) ?? false, lexicon: () => this.lexicon.getSnapshot(), @@ -221,13 +233,21 @@ export class SessionInputShell implements SessionInput { setDraft(text: DraftInput): void { const draft = resolveDraftInput(text) this.cancelCommandRestore() - if (draft.references.length === 0) this.draftEditor.setDraft(draft.text) - else this.draftEditor.restoreDraft(draft.text, draft.references) + if (draft.references.length === 0 && (draft.quotes?.length ?? 0) === 0) this.draftEditor.setDraft(draft.text) + else this.draftEditor.restoreDraft(draft.text, draft.references, draft.quotes, { remove: this.deps.quoteRemoveLabel?.() ?? '' }) if (draft.command === undefined) return this.pendingCommand = { token: draft.command.token, rev: this.rev } this.restoreCommand() } + /** @param text - selected transcript text. @returns whether the editable draft accepted the quote. */ + insertQuote(text: string): boolean { + if (this.disposed || this.snapshot.phase !== 'plain' || this.deps.quoteInsertionAllowed?.() === false || text.trim() === '') return false + const inserted = this.draftEditor.insertQuote(text.replace(/\r\n?/gu, '\n'), { remove: this.deps.quoteRemoveLabel?.() ?? '' }) + if (inserted) this.focus() + return inserted + } + /** * Reconnect optional catalog notifications and resume a pending command * chip restore without waiting for another edit. @@ -272,7 +292,9 @@ export class SessionInputShell implements SessionInput { /** Current semantic document, stable until its content revision changes. */ get draftSnapshot(): DraftSnapshot { if (this.draftSnapshotCache?.revision === this.rev) return this.draftSnapshotCache.value - const value = snapshotDraft(this.projection.clipboardText, this.projection.occurrences, this.projection.command?.token) + const value = snapshotDraft( + this.projection.clipboardText, this.projection.occurrences, this.projection.command?.token, this.projection.quotes, + ) this.draftSnapshotCache = { revision: this.rev, value } return value } @@ -744,45 +766,27 @@ export class SessionInputShell implements SessionInput { const attachmentIds = [...this.attachmentIds] this.attachmentIds = [] const occurrences = this.projection.occurrences - const record = { draft, occurrences, attachmentIds } + const quotes = this.projection.quotes + const record = { draft, occurrences, quotes, attachmentIds } this.detachedDrafts.set(attempt.seq, record) if (this.failedRestoreRev === this.rev) { this.failedDetached.clear() this.failedRestoreRev = undefined } - if (occurrences.length === 0) { - this.settleSink(attempt, this.deps.defaultSink(draft.trim(), attachmentIds, mode, attempt.signal)) - return - } const inputTriggers = this.deps.inputTriggers?.() - void Promise.all(occurrences.map(async (o) => { - if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`) - return { - offset: o.offset, - length: o.length, - text: await inputTriggers.serializeReference(o.source, o.ref, attempt.signal), - } - })).then( - (parts) => { - if (this.disposed) return - // Splice model forms over their clipboard ranges (offsets are - // clipboard-projection; parts arrive offset-sorted since chips walk in - // document order). - let out = '' - let cursor = 0 - for (const part of parts) { - out += draft.slice(cursor, part.offset) + part.text - cursor = part.offset + part.length - } - out += draft.slice(cursor) - this.settleSink(attempt, this.deps.defaultSink(out.trim(), attachmentIds, mode, attempt.signal)) - }, - (error: unknown) => { - if (this.dead(attempt)) return - const message = error instanceof Error ? error.message : String(error) - this.settleDetachedFailure(attempt, message) - }, - ) + const prompt = serializeDraftPrompt(snapshotDraft(draft, occurrences, undefined, quotes), async (source, ref) => { + if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${source}"`) + return inputTriggers.serializeReference(source, ref, attempt.signal) + }) + const send = (text: string): void => { + if (this.dead(attempt)) return + this.settleSink(attempt, this.deps.defaultSink(text, attachmentIds, mode, attempt.signal)) + } + if (!(prompt instanceof Promise)) { send(prompt); return } + void prompt.then(send, (error: unknown) => { + if (this.dead(attempt)) return + this.settleDetachedFailure(attempt, error instanceof Error ? error.message : String(error)) + }) } private notifySubmission(submission: MessageSubmission | undefined): void { @@ -832,17 +836,19 @@ export class SessionInputShell implements SessionInput { const separator = '\n\n' let draft = '' const occurrences: Occurrence[] = [] + const quotes: DraftQuote[] = [] for (const record of records) { const base = draft.length + (draft === '' ? 0 : separator.length) if (draft !== '') draft += separator draft += record.draft + quotes.push(...record.quotes.map(quote => ({ ...quote, offset: base + quote.offset }))) for (const occurrence of record.occurrences) { occurrences.push({ ...occurrence, offset: base + occurrence.offset }) } } this.restoringFailures = true try { - this.draftEditor.restoreDraft(draft, occurrences) + this.draftEditor.restoreDraft(draft, occurrences, quotes, { remove: this.deps.quoteRemoveLabel?.() ?? '' }) this.draftEditor.clearHistory() this.failedRestoreRev = this.rev } finally { diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 84aa9a4fae..6ab4c5b4a9 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -5,7 +5,6 @@ * returns; Session-scope disposal releases the shell and its resources. */ import type {} from '@deepseek-ai/dsh-client-product-analytics/client' -import type { ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/types' import type { GoalProjection } from '@deepseek-ai/dsh-goal/types' import type { Context } from '@deepseek-ai/cordis' @@ -15,12 +14,14 @@ import type { import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { ModelSelectionProjection } from '@deepseek-ai/dsh-agent/types' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { DraftAttachmentId, DraftAttachmentSerializationResult, DraftInitializationOptions, DraftInitializationResult, InputTriggerController, SessionInputResolver, SessionInput, SubmitOutcome, } from '../contract/input.ts' import type { ComposerKeyboard } from '../contract/draft-editor.ts' +import type { ComposerBlocks } from '../contract/composer-blocks.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' @@ -40,6 +41,7 @@ interface InputTriggerServiceFace { /** Attachment-send face resolved lazily to keep hub/service construction acyclic. */ interface ConversationAttachmentFace { + readonly blocks: ComposerBlocks sendSession( session: SessionFace, text: string, @@ -123,6 +125,13 @@ export class InputHub implements SessionInputResolver { popup: () => this.popup(actx), inbox: session.projections.faceOf('inbox') as ObservableSnapshot, defaultSink: (text, attachmentIds, mode, signal) => this.sink(session, text, attachmentIds, mode, signal), + quoteRemoveLabel: () => this.t('quote.remove'), + quoteInsertionAllowed: () => { + const state = session.getSnapshot() + return !state.removed && (state.subagent === null + || (state.subagent.address.mode === 'continuable' && state.subagent.parentAvailable === true)) + && this.conversation().blocks.storeFor(session.sessionId).getSnapshot() === undefined + }, steerQueue: () => { void this.steerQueue(session, shell) }, commandAttachments: { serialize: async (ids) => { diff --git a/packages/client/ui-conversation/src/client/input/prompt.ts b/packages/client/ui-conversation/src/client/input/prompt.ts new file mode 100644 index 0000000000..548256599c --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/prompt.ts @@ -0,0 +1,88 @@ +/** Serialize semantic draft spans without turning quoted references into gestures. */ +import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { DraftSnapshot } from '../contract/draft-editor.ts' + +type DraftToken = { readonly kind: 'text'; text: string } | { readonly kind: 'quote'; readonly text: string } +interface DraftSpan { + readonly offset: number + readonly length: number + readonly token: DraftToken +} + +const QUOTE_SEPARATOR = '\n\n' + +function pushText(tokens: DraftToken[], text: string): void { + if (text === '') return + const last = tokens.at(-1) + if (last?.kind === 'text') last.text += text + else tokens.push({ kind: 'text', text }) +} + +function normalizeQuoteBoundaries(tokens: DraftToken[]): DraftToken[] { + const normalized: DraftToken[] = [] + for (const token of tokens) { + if (token.kind === 'text') { + pushText(normalized, token.text) + continue + } + const previous = normalized.at(-1) + if (previous?.kind === 'text') { + previous.text = previous.text.trimEnd() + if (previous.text !== '') previous.text += QUOTE_SEPARATOR + else normalized.pop() + } + if (normalized.at(-1)?.kind === 'quote') { + normalized.push({ kind: 'text', text: QUOTE_SEPARATOR }) + } + normalized.push(token) + } + for (let index = 0; index < normalized.length; index++) { + const token = normalized[index] + if (token?.kind !== 'quote') continue + const next = normalized[index + 1] + if (next?.kind !== 'text') continue + if (next.text === QUOTE_SEPARATOR && normalized[index + 2]?.kind === 'quote') continue + next.text = next.text.trimStart() + if (next.text !== '') next.text = `${QUOTE_SEPARATOR}${next.text}` + } + return normalized.filter(token => token.kind === 'quote' || token.text !== '') +} + +/** + * Serialize the captured draft through reference codecs, formatting quotes as Markdown. + * @param draft - one semantic draft revision. + * @param serializeReference - owner-routed reference serializer. + * @returns synchronous content without references, otherwise the resolving promise. + */ +export function serializeDraftPrompt( + draft: DraftSnapshot, + serializeReference: (source: string, ref: string) => Promise, +): string | Promise { + const build = (references: readonly DraftSpan[]): string => { + const spans = [ + ...references, + ...(draft.quotes ?? []).map(quote => ({ offset: quote.offset, length: quote.length, + token: { kind: 'quote' as const, text: quote.text } })), + ].sort((a, b) => a.offset - b.offset) + const tokens: DraftToken[] = [] + let cursor = 0 + for (const span of spans) { + pushText(tokens, draft.text.slice(cursor, span.offset)) + if (span.token.kind === 'text') pushText(tokens, span.token.text) + else tokens.push(span.token) + cursor = span.offset + span.length + } + pushText(tokens, draft.text.slice(cursor)) + const first = tokens[0] + if (first?.kind === 'text') first.text = first.text.trimStart() + const last = tokens.at(-1) + if (last?.kind === 'text') last.text = last.text.trimEnd() + return normalizeQuoteBoundaries(tokens) + .map(token => token.kind === 'quote' ? formatQuoteText(token.text) : token.text).join('') + } + if (draft.references.length === 0) return build([]) + return Promise.all(draft.references.map(async reference => ({ + offset: reference.offset, length: reference.length, + token: { kind: 'text' as const, text: await serializeReference(reference.source, reference.ref) }, + }))).then(build) +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 5a221bc9a1..ac558bd2a7 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,6 +9,7 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'quote.remove': '移除引用', 'shortcut.newline': '换行', 'shortcut.complementary': '使用互补的 Queue/Steer 方式', 'shortcut.slash': '打开命令菜单', @@ -390,6 +391,7 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'quote.remove': 'Remove quote', 'shortcut.newline': 'New line', 'shortcut.complementary': 'Use the complementary Queue / Steer action', 'shortcut.slash': 'Open command menu', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 2ce7a20eef..6396387a91 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -229,7 +229,8 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load )} {renderFactorySlot('conversation.user.text', { text: row.content.filter(block => block.type === 'text').map(block => block.text).join(''), - facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true, + facts: QUEUE_TEXT_FACTS, + interactive: false, ellipsis: true, })} )} @@ -345,7 +346,9 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load )} {renderFactorySlot('conversation.user.text', { - text: submission.text, facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true, + text: submission.text, + facts: QUEUE_TEXT_FACTS, + interactive: false, ellipsis: true, })} {t('queue.sending')} {queueMutable &&
diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css index 884b2ce331..d953998ea2 100644 --- a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css @@ -17,6 +17,14 @@ padding-right: 48px; } +/* Narrow settings panel: the 48px gutter separates the label from its control + across a wide row and only squeezes the label here. */ +@media (max-width: 767px) { + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + padding-right: 16px; + } +} + .title { font-size: 14px; font-weight: 400; diff --git a/packages/client/ui-conversation/src/client/user-text/UserText.tsx b/packages/client/ui-conversation/src/client/user-text/UserText.tsx index d8cd8f3430..7513d75462 100644 --- a/packages/client/ui-conversation/src/client/user-text/UserText.tsx +++ b/packages/client/ui-conversation/src/client/user-text/UserText.tsx @@ -1,7 +1,7 @@ /** User-text presentation delegates evaluation to its data-layer document and renders JSON business nodes. */ import { Fragment, useEffect, useMemo } from 'react' import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' -import { ReferenceChip, UserTextRun } from '@deepseek-ai/dsh-client-ui-primitives' +import { HoverCardGroup, ReferenceChip, UserTextRun } from '@deepseek-ai/dsh-client-ui-primitives' import type { UserTextNodeProps } from '../contract/slots.ts' import type { UserTextNode } from '../contract/user-text.ts' import css from './UserText.module.css' @@ -42,7 +42,7 @@ export function UserText({ })} })}, [nodes, text, interactive, renderSlot]) - return {content} + return {content} } /** diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 83d6db85c9..16caa7db32 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -8,6 +8,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import * as messageQuotes from '@deepseek-ai/dsh-message-quotes/client' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages, @@ -23,6 +24,12 @@ import { createConversationStore } from '../src/client/stores.ts' import type { SessionInputShell } from '../src/client/input/facade.ts' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + 'quote-probe': { kind: 'single'; scope: 'session' } + } +} + usePinnedBrowserLanguages('zh-CN') const ROOT = 'root-1' as SessionId @@ -38,7 +45,7 @@ function sessionFakeFor() { } satisfies SessionBehaviorOverrides } -async function bench() { +async function bench(renderQuote = false) { const runtime = await SlotTestRuntime.create() const rootUpload = vi.fn(() => Promise.resolve({ ok: true as const, @@ -98,9 +105,15 @@ async function bench() { runtime.slots.installLocale(locale) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, - }, (_props: { renderSlot?: unknown }) => null) + 'quote-probe': { kind: 'single', scope: 'session' }, + }, ({ renderFactorySlot, SessionProvider }) => renderQuote ? ( + + {renderFactorySlot('conversation.user.text', { text: '> @literal /review', facts: {} })} + + ) : null) const feature = await runtime.mount({ inject: [...inject], apply }) + if (renderQuote) await runtime.mount(messageQuotes) runtime.renderRoot() const entryOf = (key: 'main.conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') => runtime.slots.entries(key)[0]! @@ -145,6 +158,17 @@ async function bench() { } describe('Conversation inject API', () => { + it('renders literal quote text through the actual Factory and keyed node registration', async () => { + const b = await bench(true) + onTestFinished(() => b.runtime.dispose()) + const errors = vi.fn() + const unsubscribe = b.slots.onEntryError(errors) + onTestFinished(unsubscribe) + await vi.waitFor(() => { expect(document.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review') }) + expect(document.querySelector('[data-slot-error]')).toBeNull() + expect(errors).not.toHaveBeenCalled() + }) + it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => { const b = await bench() onTestFinished(() => b.runtime.dispose()) diff --git a/packages/client/ui-conversation/tests/draft-document.client.spec.ts b/packages/client/ui-conversation/tests/draft-document.client.spec.ts index e96911e2d9..ef737eec06 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -13,6 +13,7 @@ import { parseStoredDraft, resolveDraftInput, snapshotDraft } from '../src/clien import { scanTextRefs } from '../src/client/input/decorations.ts' import { ReferenceChipNode } from '../src/client/input/editor/chip-node.tsx' import { CommandChipNode } from '../src/client/input/editor/command-chip-node.tsx' +import { QuoteNode } from '../src/client/input/editor/QuoteNode.tsx' import { TextRefNode } from '../src/client/input/editor/text-ref.ts' import { SessionInputShell, type SessionInputDeps } from '../src/client/input/facade.ts' @@ -302,6 +303,33 @@ describe('draft documents in the input shell', () => { await vi.waitFor(() => { expect(claim.submitted).toEqual(['ship']) }) }) + it('restores a command and an atomic quote together without changing their text or offsets', async () => { + const original = makeShell() + original.setDraft('/goal ') + expect(original.insertQuote('selected source text')).toBe(true) + expect(original.beginCommand(goalClaim(), { start: 0, end: 6, draftRev: original.snapshot.draftRev })).toBe(true) + const stored = parseStoredDraft(JSON.parse(JSON.stringify(original.draftSnapshot))) + expect(stored).toEqual({ + text: '/goal > selected source text ', references: [], command: { token: '/goal ' }, + quotes: [{ offset: 6, length: 22, text: 'selected source text' }], + }) + if (stored === undefined) throw new Error('Expected a stored draft') + const claim = goalClaim() + const provider: InputTriggerController = { + ...triggerProvider(createSnapshotStore(new Map())), + resolveClaim: () => Promise.resolve(claim), + } + const restored = makeShell({ inputTriggers: () => provider }) + restored.setDraft(stored) + await vi.waitFor(() => { expect(restored.snapshot.phase).toBe('claimed') }) + expect(commandChips(restored)).toBe(1) + expect(restored.editor.getEditorState().read(() => $nodesOfType(QuoteNode).map(node => node.getQuoteText()))) + .toEqual(['selected source text']) + expect(restored.draftSnapshot).toEqual(stored) + restored.submit() + await vi.waitFor(() => { expect(claim.submitted).toEqual(['> selected source text ']) }) + }) + it('waits for the trigger pipeline before resolving a restored command chip', async () => { const stored = storedGoalDraft() const resolveClaim = vi.fn(() => Promise.resolve(goalClaim())) diff --git a/packages/client/ui-conversation/tests/editor-focus.client.spec.ts b/packages/client/ui-conversation/tests/editor-focus.client.spec.ts index 7f115ff744..a75bdf01aa 100644 --- a/packages/client/ui-conversation/tests/editor-focus.client.spec.ts +++ b/packages/client/ui-conversation/tests/editor-focus.client.spec.ts @@ -37,6 +37,7 @@ function fixture(start = 1, end = 4) { const onUpdate = vi.fn() const runtime: DraftEditorRuntime = new DraftEditorRuntime({ onUpdate: () => { runtime.refreshProjection(); onUpdate() }, + quoteLabels: () => ({ remove: 'Remove quote' }), openReference: () => false, lexicon: () => new Map(), resolveLexicon: () => undefined, }) diff --git a/packages/client/ui-conversation/tests/interaction-registry.client.spec.ts b/packages/client/ui-conversation/tests/interaction-registry.client.spec.ts new file mode 100644 index 0000000000..197c5f02f1 --- /dev/null +++ b/packages/client/ui-conversation/tests/interaction-registry.client.spec.ts @@ -0,0 +1,61 @@ +/** Plugin dispatch is synchronous, scoped, and independent of browser default cancellation. */ +import { Context } from '@deepseek-ai/cordis' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { TextInteractionRequest } from '../src/client/contract/text-interaction.ts' +import { ConversationInteractionRegistry } from '../src/client/conversation/interaction-registry.ts' + +function fixture() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + return new ConversationInteractionRegistry(ctx) +} + +const sessionId = SessionId('interaction') +const gesture = () => ({ trigger: 'selection' as const, hasSelection: true, readText: () => 'selected', preventDefault: vi.fn() }) + +describe('Conversation interaction registrations', () => { + it('passes the session to consumers and stops at the first opened surface', () => { + const registry = fixture() + const pass = vi.fn() + const later = vi.fn() + registry.register({ id: 'pass', onInteraction: pass }) + registry.register({ id: 'quote', onInteraction: (request) => { request.open() } }) + registry.register({ id: 'later', onInteraction: later }) + const request = gesture() + expect(registry.dispatch(sessionId, request)).toBe('quote') + expect(pass).toHaveBeenCalledWith(expect.objectContaining({ sessionId, trigger: 'selection' })) + expect(later).not.toHaveBeenCalled() + expect(request.preventDefault).not.toHaveBeenCalled() + }) + + it('allows cancellation without opening and ignores retained gesture methods', () => { + const registry = fixture() + let retained: TextInteractionRequest | undefined + registry.register({ id: 'menu', onInteraction: (request) => { retained = request; request.preventDefault() } }) + const request = gesture() + expect(registry.dispatch(sessionId, request)).toBeNull() + expect(request.preventDefault).toHaveBeenCalledOnce() + retained!.open() + retained!.preventDefault() + expect(request.preventDefault).toHaveBeenCalledOnce() + }) + + it('publishes removal and rejects empty or duplicate IDs', () => { + const registry = fixture() + const changed = vi.fn() + const off = registry.subscribe(changed) + expect(() => registry.register({ id: '', onInteraction: () => {} })).toThrow('must not be empty') + const dispose = registry.register({ id: 'quote', onInteraction: (request) => { request.open() } }) + const ids = registry.getSnapshot() + expect(ids).toEqual(['quote']) + expect(registry.getSnapshot()).toBe(ids) + expect(() => registry.register({ id: 'quote', onInteraction: () => {} })).toThrow('Duplicate') + dispose() + dispose() + expect(registry.getSnapshot()).toEqual([]) + expect(changed).toHaveBeenCalledTimes(2) + expect(registry.dispatch(sessionId, gesture())).toBeNull() + off() + }) +}) diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 879f980b91..4583814093 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -148,6 +148,21 @@ function imageRow(id: string, refId: string, text = ''): UserMessage { } describe('QueueDock', () => { + it('keeps the same quote preview when a queued echo hands off to Host content', () => { + const requestId = 'quote-handoff' as SessionSnapshot['pendingSubmissions'][number]['requestId'] + const content = [{ type: 'text' as const, text: '> @literal /review' }] + const initial = { ...snapshotWith([]), pendingSubmissions: [{ requestId, placement: 'queued' as const, + time: 1000, text: content[0]!.text, attachments: [] }] } + const source = liveSession(initial) + const view = render() + expect(view.container.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review') + const queued: UserMessage = { ...row('accepted-quote', ''), content, source: { kind: 'user', rpcId: requestId } } + act(() => { source.push(snapshotWith([queued])) }) + expect(view.container.querySelectorAll('[data-quote-preview]')).toHaveLength(1) + expect(view.container.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review') + expect(view.container.querySelector('[data-ref-chip]')).toBeNull() + }) + it.each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])( 'keeps repeated queued submissions in Dock through acceptance and FIFO claims (%s)', (hostOrder) => { const pending: SessionSnapshot['pendingSubmissions'] = ['A', 'B', 'C'].map(id => ({ diff --git a/packages/client/ui-conversation/tests/quote-composer.client.spec.tsx b/packages/client/ui-conversation/tests/quote-composer.client.spec.tsx new file mode 100644 index 0000000000..7c5f62a484 --- /dev/null +++ b/packages/client/ui-conversation/tests/quote-composer.client.spec.tsx @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import type { Context } from '@deepseek-ai/cordis' +import { + KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, REDO_COMMAND, UNDO_COMMAND, + $getRoot, $getSelection, $isRangeSelection, $nodesOfType, +} from 'lexical' +import type { DraftSnapshot } from '../src/client/contract/draft-editor.ts' +import type { SubmitOutcome } from '../src/client/contract/input.ts' +import { SessionInputShell } from '../src/client/input/facade.ts' +import { + QuoteNode, QuoteNodeView, REMOVE_QUOTE_COMMAND, +} from '../src/client/input/editor/QuoteNode.tsx' +import { $selectDetectSpan } from '../src/client/input/editor/span-map.ts' + +const quote = 'selected source text' + +function bench(quoteInsertionAllowed?: () => boolean) { + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) + const shell = new SessionInputShell({ + actx: {} as Context, + defaultSink: sink, + commandAttachments: { + serialize: () => Promise.resolve([]), + release: () => {}, + unsupportedNotice: () => 'unsupported', + }, + quoteRemoveLabel: () => 'Remove quote', + ...(quoteInsertionAllowed === undefined ? {} : { quoteInsertionAllowed }), + }) + return { shell, sink } +} + +function quoteKeys(shell: SessionInputShell): string[] { + return shell.editor.getEditorState().read(() => $nodesOfType(QuoteNode).map(node => node.getKey())) +} + +describe('QuoteNode composer lifecycle', () => { + it('preserves the draft when its Session cannot accept an inserted quote', () => { + let allowed = false + const { shell } = bench(() => allowed) + shell.setDraft('base ') + const before = shell.draftSnapshot + expect(shell.insertQuote(quote)).toBe(false) + expect(shell.draftSnapshot).toBe(before) + allowed = true + expect(shell.insertQuote(quote)).toBe(true) + }) + + it('renders quote text as a non-interactive container and keeps close accessible', () => { + const dispatchCommand = vi.fn((_command: unknown, _payload: unknown) => true) + const parentKeyDown = vi.fn() + const editor = { dispatchCommand } as never + const view = render( +
+ +
, + ) + const text = view.container.querySelector('[data-quote-preview]')! + expect(text.getAttribute('role')).toBeNull() + expect(text.getAttribute('tabindex')).toBeNull() + expect(text.getAttribute('title')).toBeNull() + fireEvent.click(text) + expect(dispatchCommand).not.toHaveBeenCalled() + fireEvent.keyDown(view.getByRole('button', { name: 'Remove quote' }), { key: 'Enter' }) + expect(parentKeyDown).not.toHaveBeenCalled() + fireEvent.click(view.getByRole('button', { name: 'Remove quote' })) + expect(dispatchCommand).toHaveBeenCalledWith(REMOVE_QUOTE_COMMAND, 'quote-key') + expect(view.queryByRole('link')).toBeNull() + }) + + it('persists through DraftSnapshot restore as an atomic quote', () => { + const first = bench().shell + const mirrored: DraftSnapshot[] = [] + first.bindDraftPersistence((document) => { mirrored.push(document) }) + expect(first.insertQuote(quote)).toBe(true) + const persisted = mirrored.at(-1) + expect(persisted).toEqual({ text: '> selected source text ', references: [], quotes: [{ offset: 0, length: 22, text: quote }] }) + + const restored = bench().shell + restored.setDraft(persisted as DraftSnapshot) + restored.editor.getEditorState().read(() => { + const [node] = $nodesOfType(QuoteNode) + expect(node?.getQuoteText()).toBe(quote) + expect(node?.isKeyboardSelectable()).toBe(false) + expect(node?.createDOM({ namespace: 'quote', theme: {} }).getAttribute('contenteditable')).toBe('false') + }) + }) + + it('treats insert, close removal, Backspace, undo, and redo as whole-node operations', async () => { + const direct = bench().shell + direct.insertQuote(quote) + expect(direct.editor.dispatchCommand( + KEY_BACKSPACE_COMMAND, + new KeyboardEvent('keydown', { key: 'Backspace' }), + )).toBe(true) + await vi.waitFor(() => { expect(quoteKeys(direct)).toHaveLength(0) }) + expect(direct.snapshot.draft).toBe('') + direct.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(direct)).toHaveLength(1) }) + + const { shell } = bench() + shell.setDraft('base ') + shell.insertQuote(quote) + expect(quoteKeys(shell)).toHaveLength(1) + await Promise.resolve() + shell.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) }) + shell.editor.dispatchCommand(REDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(1) }) + + const [key] = quoteKeys(shell) + shell.editor.update(() => { + expect($selectDetectSpan({ start: 6, end: 6 })).toBe(true) + }, { discrete: true }) + expect(shell.editor.dispatchCommand( + KEY_BACKSPACE_COMMAND, + new KeyboardEvent('keydown', { key: 'Backspace' }), + )).toBe(true) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) }) + shell.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(1) }) + shell.editor.update(() => { + expect($selectDetectSpan({ start: 5, end: 5 })).toBe(true) + }, { discrete: true }) + expect(shell.editor.dispatchCommand( + KEY_DELETE_COMMAND, + new KeyboardEvent('keydown', { key: 'Delete' }), + )).toBe(true) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) }) + shell.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(1) }) + shell.editor.dispatchCommand(REMOVE_QUOTE_COMMAND, key!) + await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) }) + expect(shell.snapshot.draft).toBe('base ') + shell.editor.getEditorState().read(() => { + const selection = $getSelection() + expect($isRangeSelection(selection) && selection.isCollapsed()).toBe(true) + }) + + const empty = bench().shell + empty.insertQuote(quote) + const [emptyKey] = quoteKeys(empty) + empty.editor.dispatchCommand(REMOVE_QUOTE_COMMAND, emptyKey!) + await vi.waitFor(() => { expect(quoteKeys(empty)).toHaveLength(0) }) + expect(empty.snapshot.draft).toBe('') + }) + + it('sends selected quotes as ordinary Markdown text', async () => { + const { shell, sink } = bench() + shell.insertQuote(quote) + shell.paste('answer') + shell.submit('queue') + expect(sink).toHaveBeenCalledWith('> selected source text\n\nanswer', [], 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.draft).toBe('') + shell.editor.getEditorState().read(() => { expect($getRoot().getTextContent()).toBe('') }) + }) + + it('keeps adjacent quotes separated in model text', () => { + const { shell, sink } = bench() + shell.insertQuote('first') + shell.insertQuote('second') + shell.submit('queue') + expect(sink).toHaveBeenCalledWith('> first\n\n> second', [], 'queue', expect.any(AbortSignal)) + }) + + it('restores the complete rich document when an optimistic send fails', async () => { + let settle!: (outcome: { kind: 'error'; text: string }) => void + const { shell, sink } = bench() + sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) + shell.insertQuote(quote) + shell.paste('answer') + const submitted = shell.draftSnapshot + + shell.submit('queue') + expect(shell.snapshot.draft).toBe('') + settle({ kind: 'error', text: 'retry' }) + + await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(submitted) }) + expect(quoteKeys(shell)).toHaveLength(1) + expect(shell.notices.getSnapshot()).toMatchObject({ level: 'error', text: 'retry' }) + }) + + it('mirrors optimistic successful-send clearing through the Session lifetime', async () => { + let settle!: (outcome: { kind: 'success' }) => void + const { shell, sink } = bench() + sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) + const mirrored: DraftSnapshot[] = [] + shell.bindDraftPersistence((document) => { mirrored.push(document) }) + shell.insertQuote(quote) + shell.submit('queue') + expect(mirrored.at(-1)).toEqual({ text: '', references: [] }) + settle({ kind: 'success' }) + expect(mirrored.at(-1)).toEqual({ text: '', references: [] }) + }) +}) diff --git a/packages/client/ui-conversation/tests/quote-user-text.client.spec.tsx b/packages/client/ui-conversation/tests/quote-user-text.client.spec.tsx new file mode 100644 index 0000000000..1d88b71aee --- /dev/null +++ b/packages/client/ui-conversation/tests/quote-user-text.client.spec.tsx @@ -0,0 +1,30 @@ +/** Quote draft persistence and prompt serialization remain composer-owned. */ +import { describe, expect, it, vi } from 'vitest' +import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives' +import { parseStoredDraft } from '../src/client/draft.ts' +import { serializeDraftPrompt } from '../src/client/input/prompt.ts' + +describe('quote draft spans', () => { + it('validates canonical clipboard text and refuses overlapping references or empty quotes', () => { + const quote = { offset: 0, length: 7, text: 'quote' } + const draft = { text: '> quote', references: [], quotes: [quote] } + expect(parseStoredDraft(draft)).toEqual(draft) + expect(parseStoredDraft({ ...draft, text: 'changed' })).toBeUndefined() + expect(parseStoredDraft({ ...draft, quotes: [{ ...quote, text: ' ' }] })).toBeUndefined() + expect(parseStoredDraft({ ...draft, references: [{ source: 'file', ref: 'quote', label: 'quote', clipboardText: 'quote', offset: 2, length: 5 }] })).toBeUndefined() + expect(parseStoredDraft({ ...draft, quotes: [quote, quote] })).toBeUndefined() + }) + + it('serializes reference owners outside quotes into ordinary Markdown text', async () => { + const serialize = vi.fn(() => Promise.resolve('@[Session](dsh-session:id)')) + const quote = '/review @literal' + const quoted = formatQuoteText(quote) + const prompt = await serializeDraftPrompt({ + text: '@session ' + quoted, + references: [{ source: 'session', ref: 'id', label: 'Session', clipboardText: '@session', offset: 0, length: 8 }], + quotes: [{ offset: 9, length: quoted.length, text: quote }], + }, serialize) + expect(serialize).toHaveBeenCalledExactlyOnceWith('session', 'id') + expect(prompt).toBe('@[Session](dsh-session:id)\n\n' + quoted) + }) +}) diff --git a/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx index 8a475bc33d..f4970b398d 100644 --- a/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx +++ b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx @@ -7,6 +7,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { UserTextInput, UserTextNodeOwnerProps } from '../src/client/contract/slots.ts' import type { UserTextNode } from '../src/client/contract/user-text.ts' import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts' +import { QuotedUserText } from '@deepseek-ai/dsh-message-quotes/src/client/QuotedUserText.tsx' +import { quoteRule } from '@deepseek-ai/dsh-message-quotes/src/client/quote-rule.ts' import { UserText, PlainUserText, SessionReference } from '../src/client/user-text/UserText.tsx' import { sessionReferenceRule } from '../src/client/user-text/session-reference-rule.ts' import { collapseWhitespaceRule, trimWhitespaceRule } from '../src/client/user-text/whitespace-rules.ts' @@ -15,6 +17,8 @@ import { SkillReference } from '../../ui-skill/src/client/SkillReference.tsx' import { fileReferenceRule, folderReferenceRule } from '../../ui-reference/src/client/user-text-rules.ts' import { FileReference, FolderReference } from '../../ui-reference/src/client/FileReference.tsx' import { zh as referenceZh } from '../../ui-reference/src/client/locales.ts' +import { createSecretRule } from '../../ui-secret-mask/src/client/secret-rule.ts' +import { SecretText } from '../../ui-secret-mask/src/client/SecretText.tsx' import { goalCommandReferenceRule } from '../../ui-goal/src/client/user-text-rules.ts' import { GoalCommandReference } from '../../ui-goal/src/client/GoalCommandReference.tsx' @@ -37,7 +41,7 @@ export function createUserTextFixture(actions: UserTextFixtureActions = {}): (in onTestFinished(async () => { await ctx.fiber.dispose() }) const registry = new UserTextRegistry(ctx) for (const rule of [ - sessionReferenceRule, skillReferenceRule, fileReferenceRule, folderReferenceRule, + quoteRule, createSecretRule(), sessionReferenceRule, skillReferenceRule, fileReferenceRule, folderReferenceRule, goalCommandReferenceRule, collapseWhitespaceRule, trimWhitespaceRule, ]) registry.register(rule) const useUserTextRevision = bindSnapshotSelector({ @@ -54,6 +58,8 @@ export function createUserTextFixture(actions: UserTextFixtureActions = {}): (in const owner = input as UserTextNodeOwnerProps & { node: UserTextNode } switch (owner.node.id) { // The production components only read these owner, action and locale seats in these units. + case 'quote': return createElement(QuotedUserText, owner as ComponentProps) + case 'secret': return createElement(SecretText, { ...owner, t } as ComponentProps) case 'text': return createElement(PlainUserText, owner as ComponentProps) case 'session': return createElement(SessionReference, owner as ComponentProps) case 'file': return createElement(FileReference, { ...owner, openFile, t } as ComponentProps) @@ -63,7 +69,9 @@ export function createUserTextFixture(actions: UserTextFixtureActions = {}): (in default: return options?.fallback ?? null } } - return input => createElement(UserText, { - ...input, useUserTextRevision, createUserTextEvaluator, reportTransformFailure, renderSlot, + const renderText = (input: UserTextInput) => createElement(UserText, { + ...input, + useUserTextRevision, createUserTextEvaluator, reportTransformFailure, renderSlot, } as UserTextProps) + return renderText } diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index bdc95a4d27..51277d84c6 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 41f67832a0..67136d39c8 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -4,9 +4,10 @@ * Session's durable selection projection, then submit through the same * selectModel call. A switch made in either entry updates this shared state. */ +import type { ModelSelectionProjection } from '@deepseek-ai/dsh-agent/types' import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import type { - ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection, + ModelCatalogFailure, ModelProviderGroup, ModelSelection, } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 389b9cfc28..778a1df137 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -15,7 +15,9 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ModelSelection } from '@deepseek-ai/dsh-agent/types' +import type { ModelSelectionProjection } from '@deepseek-ai/dsh-agent/types' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm/brand' import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ModelSelectInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' @@ -98,7 +100,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { model: payload.model, ...payload.reasoningEffort === undefined ? {} - : { reasoningEffort: payload.reasoningEffort }, + : { reasoningEffort: ReasoningEffortId(payload.reasoningEffort) }, } projections.get(payload.sessionId)?.set({ lastUsed: null, next: selected }) return Promise.resolve({ ok: true as const, value: { selected } }) @@ -446,12 +448,12 @@ describe('ui-model-selection dual entry', () => { it('retains saved effort for existing and new sessions after credentials disappear', async () => { const b = await bench() try { - b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }) + b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('max') }) b.mint('existing') const existing = b.ctx.modelDirectories.directoryFor(sid('existing')) await existing.load() b.setProjected(sid('existing'), { lastUsed: null, - next: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' } }) + next: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('high') } }) b.setRoutable(false) b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) expect(existing.store.getSnapshot().retainedEffort).toBe('High') @@ -544,7 +546,7 @@ it.each([false, true])('reports accepted switches with blank=%s and no refused s const scope = b.mint('analytics', blank) try { const face = b.seat().inject!(sid('analytics')) - await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' }) + await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: ReasoningEffortId('max') }) expect(b.track).toHaveBeenCalledWith('model_switch', { ...blank ? {} : { session_id: 'analytics' }, switch_from: 'deepseek-official/deepseek-v4-flash', switch_to: 'deepseek-official/deepseek-v4-pro' }) b.track.mockClear() await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'high' }) diff --git a/packages/client/ui-model-selection/tsconfig.json b/packages/client/ui-model-selection/tsconfig.json index 82f037f52a..862648d622 100644 --- a/packages/client/ui-model-selection/tsconfig.json +++ b/packages/client/ui-model-selection/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../core/agent" + }, { "path": "../../api/remotes/tsconfig.client.json" }, diff --git a/packages/client/ui-permission-presets/README.i18n.yaml b/packages/client/ui-permission-presets/README.i18n.yaml index f9ae7348ed..9e64b4a3c4 100644 --- a/packages/client/ui-permission-presets/README.i18n.yaml +++ b/packages/client/ui-permission-presets/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-permission-presets/use-this-package: - en: 91606bff7da02253 - zh: ef68e813d64c89be + en: b4193258023cf8ae + zh: e8e6e0ea0ccb8ef4 /deepseek-ai-dsh-client-ui-permission-presets/use-this-package/the-picker: en: 4e5a070ad4321548 zh: 010063336bd2b8df diff --git a/packages/client/ui-permission-presets/README.md b/packages/client/ui-permission-presets/README.md index 50d8b4918c..cc552f5a78 100644 --- a/packages/client/ui-permission-presets/README.md +++ b/packages/client/ui-permission-presets/README.md @@ -27,6 +27,8 @@ Choose permission presets for the current Web session or future sessions. Genera Mount this plugin alongside the settings, commands, and conversation packages. General settings receives the future-default row; the composer receives the `conversation.input.permission` control, and bare `/permission` opens the slash picker. Current-session controls require both the Session projection and a complete catalog snapshot. A permission-less composition exposes neither current-session controls nor the Settings row. +In narrow browser settings, the default-permission label uses a smaller gutter before its control. Windows and macOS desktop windows retain the standard spacing. + ### The picker A pick submits the `/permission ` command line. The argument-bearing form (`/permission ` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, `Full access`, and `Auto review` in English and `仅可查看`, `工作区内修改`, `完全权限`, and `自动审查` in Chinese. Explicit host labels remain unchanged, unknown kebab-case names render in title case, and `auto` carries an `EXP` badge in menu options plus an experimental-risk confirmation. The selected composer control shows the mode without the badge. `custom` is display state, never a target. diff --git a/packages/client/ui-permission-presets/README.zh.md b/packages/client/ui-permission-presets/README.zh.md index df37cea7ec..d8c4b63854 100644 --- a/packages/client/ui-permission-presets/README.zh.md +++ b/packages/client/ui-permission-presets/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 将本插件与 settings、commands 和 conversation 包一同挂载。通用设置获得未来默认值行;composer 获得 `conversation.input.permission` 控件,裸 `/permission` 打开 slash 选择器。当前会话控件同时要求 Session 投影和完整目录快照。不提供权限功能的装配既不公开当前会话控件,也不公开设置行。 +窄屏浏览器设置中,默认权限标签与控件的间距缩小;Windows 和 macOS 桌面窗口保留标准间距。 + ### 选择器 选中即提交 `/permission ` 命令行。带参形式(直接键入 `/permission `)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write`、`Full access` 和 `Auto review`,在中文界面中是「仅可查看」「工作区内修改」「完全权限」和「自动审查」。显式 host 标签保持原样,未知 kebab-case 名称渲染为 Title Case;`auto` 在菜单选项中带有 `EXP` 标记,并在可见选择时要求实验风险确认。选中后的 composer 控件显示模式,不展示该标记。`custom` 只是显示状态,绝非目标。 diff --git a/packages/client/ui-permission-presets/src/client/PermissionRow.module.css b/packages/client/ui-permission-presets/src/client/PermissionRow.module.css index 32c6653554..e3d9b76252 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionRow.module.css +++ b/packages/client/ui-permission-presets/src/client/PermissionRow.module.css @@ -17,6 +17,14 @@ padding-right: 48px; } +/* Narrow settings panel: the 48px gutter separates the label from its control + across a wide row and only squeezes the label here. */ +@media (max-width: 767px) { + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + padding-right: 16px; + } +} + .title { font-size: 14px; font-weight: 400; diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index e820c21322..fb4ec04104 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -380,7 +380,7 @@ export const en = { installGuideFill: 'Use example', installGuideFillAria: 'Use the example {example}', installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.', - installUpgradeNotice: 'On-demand Official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.', + installUpgradeNotice: 'On-demand official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.', registryToggle: 'Registry', registryLegend: 'The npm registry the plugin is downloaded from', registryDefault: 'Default registry', diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index c4b3fe6eb0..080d24074b 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,11 +21,11 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 35403eb2a55cc77d - zh: 8f4466f7e15ae473 + en: fff68b60f4abdc7e + zh: 5cf5952ec78f7f91 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 3a31ad8de6dd0af8 - zh: 6b22881a929c33df + en: 629f57d60fe5cce1 + zh: 5263447a14c21d61 /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: 4aef65dea11de083 zh: abc22cd7c65cdf38 @@ -45,8 +45,8 @@ en: 45f4cd1ef96bb6d2 zh: f264d25b780ad1dc /deepseek-ai-dsh-client-ui-primitives/further-exploration: - en: e82b713cdb2f954b - zh: a877f34b148f433f + en: abbbdbc9a7540743 + zh: cf5bbeaedb3696d0 /deepseek-ai-dsh-client-ui-primitives/model-experience: en: 3120df665299c634 zh: 1d9f365d091d0d2a diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4bfca87305..48e3967d34 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -94,15 +94,17 @@ The catalog above lists what each export is for; this section covers the behavio -`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. +`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. `as="span"` preserves an inline text container. ### Rendering agent output +The pure Markdown helper `formatQuoteText` normalizes CRLF and CR line endings, then prefixes each line with `> `. + The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). -`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. +`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. `textInteractionSource` marks its existing root as a source for text selection interactions. `CodeBlock.toolbarLabels` enables the shared code-card header and spacing. `ReadBlock` and `DiffBlock` use the same header: tertiary-colored language and filename text, secondary-colored copy and wrap icons, and hover or keyboard-focus tooltips. Missing or unsupported languages use the owner’s localized code-block title; mixed-language diffs use that title too. Code cards share typography and content insets; their headers and bodies use the same background in both themes. Read gutters fit the largest returned line number, and diff fills cover the full scrollable row. The wrap toggle keeps a fixed accessible name and exposes its state through `aria-pressed`; its tooltip describes the next action. Wrapping is local to each mounted card and never changes copied text; code fences start wrapped, while reads and diffs preserve source columns until wrapping is enabled. Compact Markdown shares this toolbar, spacing, and code typography while retaining monochrome text. With `toolbarLabels`, source previews can pass `wrap` to follow their owner’s preference and show only the language and copy icon; owner CSS retains the source layout. @@ -110,7 +112,7 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree. -`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. +`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Its transparent hover region connects the anchor to the panel across the visual gap, so crossing the gap does not activate content beneath it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. ### Localizing copy @@ -184,6 +186,8 @@ These pages place the atoms in the client stack and the design system. ----- +`QuotePreview` shares the compact quote excerpt and clipped-only inline HoverCard between composers and sent messages. The popover sizes to its text up to 300px and stays within the viewport. Both surfaces display the exact text; editable drafts may supply a localized whole-quote removal action. Quotes opt into the nearest `HoverCardGroup` for exclusive preview display. The primitive performs no recognition or navigation. + ## Model Experience diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e5ebe6f486..92b794e0ce 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -94,15 +94,17 @@ kind: "package-library" -`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 +`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。`as="span"` 保留行内文本容器。 ### 渲染 agent 输出 +纯 Markdown 工具 `formatQuoteText` 规范化 CRLF 和 CR 换行符,再为每行添加 `> ` 前缀。 + 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 -`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 +`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 `textInteractionSource` 将已有根元素标记为文本选区交互的来源。 `CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:tertiary 色的语言名称与文件名、secondary 色的复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距;在两种主题下,标题栏和正文都使用相同背景。读取卡片的行号栏容纳返回窗口中的最大行号,Diff 底色覆盖完整可滚动行。换行按钮保留固定的无障碍名称,以 `aria-pressed` 表达状态;提示文本说明下一步操作。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 共用此工具栏、间距和代码字体排版,同时保留单色文本。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。 @@ -110,7 +112,7 @@ kind: "package-library" `JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。 -`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 +`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 透明悬停区域连接锚点与弹层之间的视觉空隙,鼠标经过时不会激活下方内容。 ### 本地化文案 @@ -184,6 +186,8 @@ kind: "package-library" ----- +`QuotePreview` 在 composer 与已发送消息之间共享紧凑 quote 摘录和仅截断时显示的行内 HoverCard。原文弹层按文本内容确定宽度,最大为 300px,并限制在视口内。两处均展示原文;可编辑草稿可以提供本地化的整条 quote 删除操作。quote 加入最近的 `HoverCardGroup`,使原文弹层互斥显示。该 primitive 不负责识别或导航。 + ## 模型体验 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 50c62fa828..5679f43bd3 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,3 +88,16 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } + +.fitContent { + width: max-content; +} + +.portal { + display: contents; +} + +.bridge { + position: fixed; + z-index: 100; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index e30d671c36..157cf35f06 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -16,6 +16,15 @@ const INLINE_PREVIEW_WIDTH = 300 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 +interface CardPosition { + left: number + top: number + width?: number + maxWidth?: number + maxHeight?: number + bridge?: { left: number; top: number; width: number; height: number } +} + /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. * Touch entry does not open a preview or intercept the anchor's action. @@ -27,6 +36,8 @@ const VIEWPORT_MARGIN = 8 * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. + * Its hover region connects the anchor and preview across the visual gap. + * @param props.fitContent - size an inline card to its content within the usual width cap. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. @@ -38,11 +49,12 @@ const VIEWPORT_MARGIN = 8 */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, grouped = false, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, fitContent = false, }: { anchor: ReactNode /** Inline media preview using the shared menu material and keyboard focus. */ inline?: boolean + fitContent?: boolean content: ReactNode openDelayMs?: number disabled?: boolean @@ -56,6 +68,7 @@ export function HoverCard({ })) { const rootRef = useRef(null) const cardRef = useRef(null) + const portalRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) @@ -65,7 +78,7 @@ export function HoverCard({ const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) + const [pos, setPos] = useState(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) @@ -107,8 +120,8 @@ export function HoverCard({ const groupVisible = open && positioned useLayoutEffect(() => { if (!groupVisible || group === null) return - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The visible card's ref is attached before layout effects. - group.shown(groupOwner, dismissImmediately, cardRef.current!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The visible portal's ref is attached before layout effects. + group.shown(groupOwner, dismissImmediately, portalRef.current!) return () => { group.release(groupOwner) } }, [group, groupOwner, groupVisible, dismissImmediately]) @@ -180,17 +193,25 @@ export function HoverCard({ } if (inline) { const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) + const maxWidth = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) + const width = fitContent ? Math.min(cardRef.current?.offsetWidth ?? maxWidth, maxWidth) : maxWidth const topMargin = overlayTopMargin(VIEWPORT_MARGIN) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below const maxHeight = onTop ? above : below + const left = Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)) + const top = onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop + const bridgeTop = onTop ? top + Math.min(height, maxHeight) : r.bottom + const bridgeLeft = Math.min(r.left, left) setPos({ - left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), - top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, - width, maxHeight, + left, top, ...fitContent ? {} : { width }, maxWidth, maxHeight, + bridge: { + left: bridgeLeft, top: bridgeTop, + width: Math.max(r.right, left + width) - bridgeLeft, + height: Math.max(0, (onTop ? r.top : top) - bridgeTop), + }, }) return } @@ -209,7 +230,7 @@ export function HoverCard({ window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, inline]) + }, [open, variant, widthAnchorRef, positioned, inline, fitContent]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The @@ -240,7 +261,7 @@ export function HoverCard({ const copyable = copyText !== undefined const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. - if (cardRef.current?.contains(event.target as Node)) return + if (portalRef.current?.contains(event.target as Node)) return clearTimer() cancelClose() close() @@ -248,10 +269,11 @@ export function HoverCard({ const card = open && pos !== null && (
{anchor} {open && copyable && {copied ? copiedLabel : ''}} - {card !== false && createPortal(card, document.body)} + {card !== false && createPortal( + + {pos.bridge !== undefined && } + {card} + , document.body, + )} ) } diff --git a/packages/client/ui-primitives/src/HoverCardGroup.tsx b/packages/client/ui-primitives/src/HoverCardGroup.tsx index 163202d813..a218110e1c 100644 --- a/packages/client/ui-primitives/src/HoverCardGroup.tsx +++ b/packages/client/ui-primitives/src/HoverCardGroup.tsx @@ -16,9 +16,14 @@ export const HoverCardGroupContext = createContext(null) * Share first-display dwell across a list and its active portal, including the card's existing pointer grace. * @param props.children - list contents, including opted-in HoverCards. * @param props.className - classes for the physical list container. + * @param props.as - physical container tag; use span to preserve inline text layout. * @returns the list container and its private hover coordinator. */ -export function HoverCardGroup({ children, className }: { children: ReactNode; className?: string }) { +export function HoverCardGroup({ children, className, as: Container = 'div' }: { + children: ReactNode + className?: string | undefined + as?: 'div' | 'span' +}) { const [group] = useState(() => { let pointer: 'outside' | 'list' | 'card' = 'outside' let warm = false @@ -52,7 +57,7 @@ export function HoverCardGroup({ children, className }: { children: ReactNode; c }) return ( -
{ const overCard = group.containsCard(event.target) @@ -66,7 +71,7 @@ export function HoverCardGroup({ children, className }: { children: ReactNode; c }} > {children} -
+
) } diff --git a/packages/client/ui-primitives/src/QuotePreview.module.css b/packages/client/ui-primitives/src/QuotePreview.module.css new file mode 100644 index 0000000000..1418d8393c --- /dev/null +++ b/packages/client/ui-primitives/src/QuotePreview.module.css @@ -0,0 +1,101 @@ +.quote { + display: inline-flex; + align-items: baseline; + gap: 4px; + box-sizing: border-box; + max-width: min(80%, 300px); + margin: 0; + padding: 0; + vertical-align: baseline; + color: var(--dsw-alias-state-business-primary); + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(20px + var(--dsh-content-font-delta, 0px)); +} + +.popoverText { + display: block; + padding: 8px 10px; + color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 20px; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.source { + min-width: 0; + padding: 0; + border: none; + outline: none; + background: none; + color: inherit; + font: inherit; + line-height: inherit; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.marker, +.remove { + display: grid; + place-items: center; + align-self: center; + flex: none; + width: 16px; + height: 16px; + padding: 0; + color: inherit; + user-select: none; +} + +.marker { + pointer-events: none; +} + +.remove { + border: none; + border-radius: 50%; + corner-shape: round; + background: transparent; + cursor: pointer; +} + +.quoteMark, +.closeMark { + grid-area: 1 / 1; + transition: opacity 120ms ease; +} + +.closeMark { + opacity: 0; +} + +.quote[data-removable]:focus-within .quoteMark { + opacity: 0; +} + +.quote[data-removable]:focus-within .closeMark { + opacity: 1; +} + +@media (hover: hover) { + .quote[data-removable]:hover .quoteMark { + opacity: 0; + } + + .quote[data-removable]:hover .closeMark { + opacity: 1; + } +} + +@media (hover: none) { + .quote[data-removable] .quoteMark { + opacity: 0; + } + + .quote[data-removable] .closeMark { + opacity: 1; + } +} diff --git a/packages/client/ui-primitives/src/QuotePreview.tsx b/packages/client/ui-primitives/src/QuotePreview.tsx new file mode 100644 index 0000000000..4cc56d2cec --- /dev/null +++ b/packages/client/ui-primitives/src/QuotePreview.tsx @@ -0,0 +1,103 @@ +/** Shared compact presentation for draft and sent text quotes. */ + +import { useLayoutEffect, useRef, useState } from 'react' +import type { JSX, MouseEvent as ReactMouseEvent } from 'react' +import { IconCloseOutlineRegular } from './icons/index.tsx' +import { HoverCard } from './HoverCard.tsx' +import css from './QuotePreview.module.css' + +function QuoteMarkIcon({ className }: { readonly className?: string | undefined }) { + return ( + + + + + + + ) +} + +/** Optional whole-quote removal control supplied by an editable owner. */ +export interface QuotePreviewRemoval { + /** Localized accessible name for the removal button. */ + readonly label: string + /** Remove the complete owning quote node. */ + readonly onRemove: () => void +} + +/** Presentation inputs for one compact quote excerpt. */ +export interface QuotePreviewProps { + /** Exact selected text rendered inline and exposed in a popover while clipped. */ + readonly text: string + /** Composer-only removal action; omit for inert sent quotes. */ + readonly removal?: QuotePreviewRemoval | undefined +} + +/** + * Render one width-capped quote preview shared by editable drafts and sent messages. + * Clipped excerpts open a content-sized, scrollable popover that fits within the viewport. + * @param props - selected text and optional localized removal action. + * @returns a non-navigating quote preview with a full-text popover only while clipped. + */ +export function QuotePreview({ text, removal }: QuotePreviewProps): JSX.Element { + const sourceRef = useRef(null) + const [truncated, setTruncated] = useState(false) + useLayoutEffect(() => { + const source = sourceRef.current + /* v8 ignore next -- the ref is attached before layout effects run. */ + if (source === null) return + const measure = () => { + const clipped = source.scrollWidth > source.clientWidth + setTruncated(clipped) + } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(source) + return () => { observer.disconnect() } + }, [text]) + const remove = (event: ReactMouseEvent): void => { + event.preventDefault() + event.stopPropagation() + removal?.onRemove() + } + const preview = ( + + {removal === undefined + ? ( + + + + ) + : ( + + )} + {text} + + ) + return ( +