From a77e23a975c6f5502448606e2618e4ed769bdf9c Mon Sep 17 00:00:00 2001 From: "yx.zhang" Date: Wed, 26 Aug 2026 16:40:11 +0800 Subject: [PATCH 01/18] feat(ui): unify the flow-row secondary font tier and scale tables Derive --dsh-content-font-size-secondary (setting -1 at <=14, setting -2 above; 13px at the default) with --dsh-content-font-delta-secondary in gradient-shadow-text.css, and move every one-step-under-the-body text onto it: think text and reasoning summaries, the shared DisclosureRow title, ToolRow and bash-row summaries and file links, compaction/ context/command/retry/error rows, StatsLine, the workflow-run panel tiers, reference summaries, the turn-status clock, and the feedback note trigger. The markdown table variants join the same tier instead of staying fixed; its 13px floor keeps them legible at the 12px setting. At the default setting the flow-row titles and summaries render at 13px (previously 14px) so they match think text at every setting instead of sitting 2px above it. --- ...08-18-settings-font-size-control.i18n.yaml | 4 +-- .../2026-08-18-settings-font-size-control.md | 6 ++--- ...026-08-18-settings-font-size-control.zh.md | 6 ++--- .../src/client/chat/ChatView.module.css | 12 ++++----- .../chat/ContextInjectionRow.module.css | 4 +-- .../client/chat/GenericCommandCard.module.css | 2 +- .../src/client/chat/MessageItem.module.css | 22 ++++++++-------- .../src/client/chat/ReasoningRow.module.css | 13 +++++----- .../src/client/chat/StatsLine.module.css | 4 +-- .../chat/TurnUsageDisclosure.module.css | 4 +-- .../chat-font-axis-styles.client.spec.ts | 14 +++++----- .../client/MessageFeedbackActions.module.css | 2 +- .../src/DisclosureRow.module.css | 12 +++++---- .../disclosure-row-styles.client.spec.ts | 4 +-- packages/client/ui-theme/README.i18n.yaml | 4 +-- packages/client/ui-theme/README.md | 4 +-- packages/client/ui-theme/README.zh.md | 4 +-- .../src/styles/gradient-shadow-text.css | 21 +++++++++------ .../client/tool/components/ToolRow.module.css | 14 +++++----- .../tool/toolviews/bash-sample.module.css | 4 +-- .../tests/tool-row-styles.client.spec.ts | 7 ++--- .../src/client/WorkflowRunPanel.module.css | 26 +++++++++---------- .../tests/panel-styles.client.spec.ts | 8 +++--- 23 files changed, 106 insertions(+), 95 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml index 53ae33271d..a540a7aa9c 100644 --- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md -2026-08-18-settings-font-size-control.md: 34ee7f28f58a0a5a5dc8cd39bc4cecf5e081359f -2026-08-18-settings-font-size-control.zh.md: 2c2ea714d50ca1f934d78ede6175935338488331 +2026-08-18-settings-font-size-control.md: 9e8b933653359bd45abf34e45fec5497289435ec +2026-08-18-settings-font-size-control.zh.md: cff574bd5009db6d4d855384c10d6f9a034ea343 diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md index 34ee7f28f5..9e8b933653 100644 --- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md @@ -14,7 +14,7 @@ The conversation's body text size was fixed (14px after the 0.875 markdown-ladde **Presentation rides the existing snapshot pipeline.** The service never touches the DOM: ui-layout's `ThemePresenter` writes `--dsh-content-font-size` on `body` from each snapshot (and retracts it on dispose), and the Host boot script embeds the durable value in the index response so first paint uses the chosen size — the same pre-plugin path the dark-mode attribute takes, avoiding a font-size flash. -**One CSS delta variable moves the ladder.** `gradient-shadow-text.css` derives `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)` and shifts the markdown h1–h4 and base variants (size and line height) by that same px increment, preserving the heading hierarchy and each variant's leading. Table, small, and code variants stay fixed — as does the interrupted-turn `.stopped` tag (11px): they are dense secondary text whose defaults would fall below legibility when stepped down. Consumers outside the token ladder read `var(--dsh-content-font-size, 14px)` (or `calc( + var(--dsh-content-font-delta, 0px))` for smaller steps) and `calc( + var(--dsh-content-font-delta, 0px))` directly: the assistant narration root, the user bubble (reference summaries and their inline glyphs included), the composer card (whose textarea/mirror/backdrop stack inherits font metrics from the card by design), and the flow chrome around them — the shared DisclosureRow header (tool calls, think, commands; row height, title, and leading box all move) with its expanded bodies' `22px + delta` indent keeping content aligned under the shifted title start, ToolRow/bash-row summaries and file links, think text (12px keeping its 2px step under the body), compaction/context/retry/error rows, StatsLine, the chat hint and open-error strips, the workflow-run panel (run/phase headers and expanded member rows), the message clock and icon actions (slot-injected message-feedback actions match through the same variables), and the turn status line. Flow icons scale through each leading box's CSS edge (`svg` width/height overriding the glyph attributes); StateDot is exempt via its `data-state` attribute — a status mark, not text furniture. The 14px fallbacks keep every surface pixel-identical when the variable is absent (tests, storybook-like mounts, remote compositions before adoption). +**One CSS delta variable moves the ladder, plus a derived secondary tier.** `gradient-shadow-text.css` derives `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)` and shifts the markdown h1–h4 and base variants (size and line height) by that same px increment, preserving the heading hierarchy and each variant's leading. Secondary-tier text — one step under the body — reads `--dsh-content-font-size-secondary: min(setting − 1px, max(13px, setting − 2px))`: setting −1 at ≤14, setting −2 above (12→11, 13→12, 14→13, 15→13, 16→14, 17→15), with `--dsh-content-font-delta-secondary` (secondary − 13px) moving its line heights. The secondary tier covers the markdown table variants, the shared DisclosureRow title (tool calls, think, commands), ToolRow/bash-row summaries and file links, think text, compaction/context/retry/error rows, StatsLine, the chat hint and open-error strips, the workflow-run panel headers/counts/statuses, the turn-status clock, reference summaries, and the note-trigger label. Small and code variants stay fixed — as does the interrupted-turn `.stopped` tag (11px): they are dense secondary text whose defaults would fall below legibility when stepped down. Consumers outside the token ladder at the body size read `var(--dsh-content-font-size, 14px)` and `calc( + var(--dsh-content-font-delta, 0px))` directly: the assistant narration root, the user bubble (inline reference glyphs included), the composer card (whose textarea/mirror/backdrop stack inherits font metrics from the card by design), the compaction/DisclosureRow geometry (row height, leading box, expanded bodies' `22px + delta` indent), the message clock and icon actions (slot-injected message-feedback actions match through the same variables), and the turn status line. Flow icons scale through each leading box's CSS edge (`svg` width/height overriding the glyph attributes); StateDot is exempt via its `data-state` attribute — a status mark, not text furniture. The fallbacks (14px body, 13px secondary) keep every surface pixel-identical when the variables are absent (tests, storybook-like mounts, remote compositions before adoption). **The stepper is a pill, not a menu.** The row reuses the selector-pill geometry (h36 r18 module fill) with the value centered in the pill, the up/down arrow column revealed on hover/focus-within and absolutely anchored to the pill's right edge (so revealing never moves the value), and a `px` unit label after the pill. A tertiary description line under the title states the scope — the size only affects conversation content, not the application chrome. Arrows disable at the bounds; the display follows the store mirror, never the click echo — the same store/face pattern as the Appearance row. @@ -24,8 +24,8 @@ The conversation's body text size was fixed (14px after the 0.875 markdown-ladde **Scaling via a multiplier (`em`/percentage) instead of a px delta.** Rejected: multiplying spreads the 12–17px range disproportionately across the ladder (21px h1 would swing ~18–25.5px) and produces fractional line heights; the fixed px shift keeps every step integer and the hierarchy's px gaps intact. -**Scaling every font token (tables, code, small).** Rejected: those variants are secondary/dense by design; at −2 the small ladder would hit 10px and code 9px, below legibility. +**Scaling every font token (small, code).** Rejected: those variants are dense by design; at −2 the small ladder would hit 10px and code 9px, below legibility. The table variants instead join the secondary tier, whose 13px floor keeps them legible at every setting. ## Consequences -The 0.875 markdown-ladder rescale (body 16 → 14) ships with this change as the new default rendering; at delta 0 every axis consumer is pixel-identical to that rescaled baseline, and surfaces without the variable fall back to the same 14px. A changed size persists in `$DSH_HOME/settings.yaml`, survives reloads without flashing (the boot script writes the durable value pre-hydration and `ThemeRuntime` seeds its initial snapshot from it), applies live across transcript and composer, and remote browsers keep the process-local-selection rule the theme preference already has. `setFontSize` joins the model-visible cordis client API catalog beside `setTheme`. +The 0.875 markdown-ladder rescale (body 16 → 14) ships with this change as the new default rendering; at the default setting the body-size consumers match that rescaled baseline, and the secondary tier renders at 13px (one step down — a deliberate demotion of the flow-row titles and summaries that previously sat at the body size). Surfaces without the variables fall back to the same defaults. A changed size persists in `$DSH_HOME/settings.yaml`, survives reloads without flashing (the boot script writes the durable value pre-hydration and `ThemeRuntime` seeds its initial snapshot from it), applies live across transcript and composer, and remote browsers keep the process-local-selection rule the theme preference already has. `setFontSize` joins the model-visible cordis client API catalog beside `setTheme`. diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md index 2c2ea714d5..cff574bd50 100644 --- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md @@ -14,7 +14,7 @@ **呈现走既有快照管线。**服务绝不接触 DOM:ui-layout 的 `ThemePresenter` 依据每份快照在 `body` 上写 `--dsh-content-font-size`(dispose 时收回),Host 引导脚本把持久化值嵌入 index 响应,让首帧就使用所选字号——与暗色属性同一条插件前路径,避免字号闪变。 -**一个 CSS 增量变量平移阶梯。**`gradient-shadow-text.css` 派生 `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)`,把 markdown h1–h4 与 base 各变体(字号与行高)按同一像素增量平移,保持标题层级与各变体的行距。表格、small 与 code 变体保持固定——中断回合的 `.stopped` 标签(11px)同样固定:它们是密集次级文本,其默认值在字号下调时会低于可读下限。token 阶梯之外的消费方直接读取 `var(--dsh-content-font-size, 14px)`(较小档位则用 `calc(<自身默认> + var(--dsh-content-font-delta, 0px))`)与 `calc(<默认行高> + var(--dsh-content-font-delta, 0px))`:助手正文根节点、用户气泡(含引用摘要及其行内字形)、composer 卡片(其 textarea/mirror/backdrop 三层按设计从卡片继承字体度量),以及围绕它们的流内 chrome——共享的 DisclosureRow 头部(工具调用、think、命令;行高、标题与 leading 盒同步移动),其展开内容以 `22px + delta` 缩进跟随平移后的标题起点保持对齐、ToolRow/bash 行的 summary 与文件链接、think 正文(12px,保持比正文小 2px 的层级)、compaction/context/retry/错误行、StatsLine、chat 提示与打开失败条、workflow-run 面板(run/phase 头部与展开的成员行)、消息时钟与图标操作(slot 注入的消息反馈操作经同一对变量同步缩放),以及回合状态行。流内图标经由各 leading 盒的 CSS 边长缩放(`svg` width/height 覆盖字形自身属性);StateDot 通过其 `data-state` 属性豁免——它是状态标记,不是文字组件。14px 回退让变量缺席时(测试、独立挂载、采纳前的远程组合)所有表面逐像素不变。 +**一个 CSS 增量变量平移阶梯,另派生一个低一档层级。**`gradient-shadow-text.css` 派生 `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)`,把 markdown h1–h4 与 base 各变体(字号与行高)按同一像素增量平移,保持标题层级与各变体的行距。低一档文本——比正文低一档——读 `--dsh-content-font-size-secondary: min(设置值 − 1px, max(13px, 设置值 − 2px))`:设置 ≤14 时为设置值 −1,>14 时为设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15),其行高经 `--dsh-content-font-delta-secondary`(低一档值 − 13px)随动。低一档覆盖:markdown 表格变体、共享 DisclosureRow 标题(工具调用、think、命令)、ToolRow/bash 行的 summary 与文件链接、think 正文、compaction/context/retry/错误行、StatsLine、chat 提示与打开失败条、workflow-run 面板的头部/计数/状态、回合状态时钟、引用摘要与笔记触发标签。small 与 code 变体保持固定——中断回合的 `.stopped` 标签(11px)同样固定:它们是密集次级文本,其默认值在字号下调时会低于可读下限。token 阶梯之外按正文档读取的消费方直接使用 `var(--dsh-content-font-size, 14px)` 与 `calc(<默认行高> + var(--dsh-content-font-delta, 0px))`:助手正文根节点、用户气泡(含行内引用字形)、composer 卡片(其 textarea/mirror/backdrop 三层按设计从卡片继承字体度量)、compaction/DisclosureRow 的行几何(行高、leading 盒、展开内容的 `22px + delta` 缩进)、消息时钟与图标操作(slot 注入的消息反馈操作经同一对变量同步缩放),以及回合状态行。流内图标经由各 leading 盒的 CSS 边长缩放(`svg` width/height 覆盖字形自身属性);StateDot 通过其 `data-state` 属性豁免——它是状态标记,不是文字组件。回退值(正文 14px、低一档 13px)让变量缺席时(测试、独立挂载、采纳前的远程组合)所有表面逐像素不变。 **步进器是药丸控件,不是菜单。**该行复用选择器药丸几何(h36 r18 模块填充),数值在药丸内居中,上下箭头列在 hover/focus-within 时显示并绝对定位在药丸右缘(显示时数值不移动),药丸后带 `px` 单位标签。标题下方的三级说明行标明作用范围——字号仅影响会话内容,不影响应用外框。到达边界时对应箭头禁用;显示跟随 store 镜像,绝不跟随点击回声——与外观行相同的 store/face 模式。 @@ -24,8 +24,8 @@ **用倍率(`em`/百分比)而非像素增量缩放。**否决:乘法会让 12–17px 的范围在阶梯上不成比例地放大(21px 的 h1 会摆动到约 18–25.5px),并产生小数行高;固定像素平移让每一档都是整数,层级间的像素差保持不变。 -**缩放全部字体 token(表格、code、small)。**否决:这些变体按设计就是次级/密集文本;−2 档时 small 阶梯会降到 10px、code 降到 9px,低于可读下限。 +**缩放全部字体 token(code、small)。**否决:这些变体按设计就是密集文本;−2 档时 small 阶梯会降到 10px、code 降到 9px,低于可读下限。表格变体改为并入低一档层级,其 13px 下限在任何设置下都保持可读。 ## 后果 -0.875 的 markdown 阶梯重缩放(正文 16 → 14)作为新的默认渲染随本变更一同交付;增量为 0 时所有轴消费方与该重缩放基线逐像素一致,无变量的表面回退到同样的 14px。修改后的字号持久化在 `$DSH_HOME/settings.yaml`,重载不闪变(引导脚本在 hydration 前写入持久化值,`ThemeRuntime` 以它为初始快照种子),在转录与 composer 上实时生效;远程浏览器沿用主题偏好既有的进程内选择规则。`setFontSize` 与 `setTheme` 一同进入模型可见的 cordis 客户端 API 目录。 +0.875 的 markdown 阶梯重缩放(正文 16 → 14)作为新的默认渲染随本变更一同交付;默认设置下正文档消费方与该重缩放基线一致,低一档渲染为 13px(低一步——这是对此前按正文档渲染的流内行标题与摘要的有意降档)。无变量的表面回退到同样的默认值。修改后的字号持久化在 `$DSH_HOME/settings.yaml`,重载不闪变(引导脚本在 hydration 前写入持久化值,`ThemeRuntime` 以它为初始快照种子),在转录与 composer 上实时生效;远程浏览器沿用主题偏好既有的进程内选择规则。`setFontSize` 与 `setTheme` 一同进入模型可见的 cordis 客户端 API 目录。 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 f2f5a3a908..502ae295be 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -101,8 +101,8 @@ .turnStatusClock { margin-left: 8px; font: var(--dsw-font-xs-13); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); font-weight: 400; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-caption); @@ -125,14 +125,14 @@ .hint { color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } .openError { color: var(--dsw-alias-state-error-primary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } .older { diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css index e9173a078d..788322535c 100644 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css @@ -29,7 +29,7 @@ min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; @@ -41,7 +41,7 @@ min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css index 075029de01..49297685ae 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css @@ -55,7 +55,7 @@ overflow: hidden; flex: 1 1 auto; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index ff47ae7717..7cdb951e01 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -42,8 +42,8 @@ .referenceSummary { color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } .contextRow { padding: 2px 0; @@ -120,7 +120,7 @@ .compactionTitle { flex: none; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-primary-dimmed); } @@ -139,7 +139,7 @@ min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; @@ -149,14 +149,14 @@ /* Indent under the title, which starts at leading (16 + delta) + gap 6. */ padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); } .retryRow { color: var(--dsw-alias-label-tertiary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); } .retrySummary { @@ -227,8 +227,8 @@ margin-top: 3px; padding-left: 14px; overflow-wrap: anywhere; - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } .retryDetailLabel { @@ -241,8 +241,8 @@ gap: 8px; align-items: start; padding: 2px 0; - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); } .turnErrorDot { diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 67cfbf737a..2d7cbe8f07 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -50,15 +50,16 @@ background: var(--dsw-alias-label-caption); } -/* Think text sits one 2px step under the body size and follows the Settings - font-size preference through the same px delta, keeping the step. */ +/* Think text reads the secondary tier — one step under the body size + (setting −1 at ≤14, setting −2 above), shared with every flow-row title + and summary. */ .summary { min-width: 0; overflow: hidden; flex: 1 1 auto; color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; white-space: nowrap; } @@ -71,8 +72,8 @@ /* Indent under the title, which starts at leading (16 + delta) + gap 6. */ padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); white-space: pre-wrap; word-break: break-word; } diff --git a/packages/client/ui-chat/src/client/chat/StatsLine.module.css b/packages/client/ui-chat/src/client/chat/StatsLine.module.css index b2d7989cde..8d997d93ce 100644 --- a/packages/client/ui-chat/src/client/chat/StatsLine.module.css +++ b/packages/client/ui-chat/src/client/chat/StatsLine.module.css @@ -11,8 +11,8 @@ margin: 0 auto; box-sizing: border-box; padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px; - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; diff --git a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css b/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css index b0a8dbad02..ba96155504 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css @@ -29,9 +29,9 @@ min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: 14px; + font-size: var(--dsh-content-font-size-secondary, 13px); font-variant-numeric: tabular-nums; - line-height: 24px; + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; } 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 6f94a95ab3..d0b8c9cb3a 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 @@ -19,25 +19,25 @@ function declarationsFrom(source: string, selector: string): string[] { } describe('chat flow font-size axis', () => { - it('think text keeps its 2px step under the body size and moves with the delta', () => { + it('think text reads the secondary tier (one step under the body size)', () => { const css = read('ReasoningRow.module.css') for (const selector of ['.summary', '.thinkBody']) { expect(declarationsFrom(css, selector)).toEqual(expect.arrayContaining([ - 'font-size: calc(12px + var(--dsh-content-font-delta, 0px))', - 'line-height: calc(20px + var(--dsh-content-font-delta, 0px))', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px))', ])) } }) - it('command and context summaries ride the axis at the body size', () => { + it('command and context summaries read the secondary tier on the shared row line', () => { expect(declarationsFrom(read('GenericCommandCard.module.css'), '.summary')).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) const context = read('ContextInjectionRow.module.css') for (const selector of ['.source', '.summary']) { expect(declarationsFrom(context, selector)).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) } @@ -60,7 +60,7 @@ describe('chat flow font-size axis', () => { const css = read('MessageItem.module.css') for (const selector of ['.compactionTitle', '.compactionSummary', '.compactionBody']) { expect(declarationsFrom(css, selector)).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) } diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css index 6948f2eb56..a8c16c9383 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css @@ -46,7 +46,7 @@ border-radius: 14px; background: transparent; color: var(--dsw-alias-label-tertiary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(28px + var(--dsh-content-font-delta, 0px)); white-space: nowrap; text-overflow: ellipsis; diff --git a/packages/client/ui-primitives/src/DisclosureRow.module.css b/packages/client/ui-primitives/src/DisclosureRow.module.css index 212c11411b..fed453f345 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.module.css +++ b/packages/client/ui-primitives/src/DisclosureRow.module.css @@ -1,8 +1,10 @@ -/* Shared disclosure header: [16px leading] gap 6 [title 14/24] at the default +/* Shared disclosure header: [16px leading] gap 6 [title 13/24] at the default size. The Settings font-size preference moves the row through the - body-published axis: title size follows --dsh-content-font-size, and the row - height, leading box, and glyph edge shift by the same px delta so the icon - keeps its optical share of the line. */ + body-published axis: title size follows the secondary tier + (--dsh-content-font-size-secondary: one step under the body — setting −1 at + ≤14, setting −2 above), and the row height, leading box, and glyph edge + shift by the body px delta so the icon keeps its optical share of the + line. */ .root { display: flex; @@ -76,7 +78,7 @@ button.leading { .title { flex: none; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); } diff --git a/packages/client/ui-primitives/tests/disclosure-row-styles.client.spec.ts b/packages/client/ui-primitives/tests/disclosure-row-styles.client.spec.ts index 4b1ce4a4ef..52bb220b7e 100644 --- a/packages/client/ui-primitives/tests/disclosure-row-styles.client.spec.ts +++ b/packages/client/ui-primitives/tests/disclosure-row-styles.client.spec.ts @@ -19,9 +19,9 @@ function declarations(selector: string): string[] { } describe('DisclosureRow.module.css font-size axis', () => { - it('sizes the title from the content font axis with the shared px delta', () => { + it('sizes the title from the secondary content tier on the shared row line', () => { expect(declarations('.title')).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) }) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 63a4525d4b..8af8bc0e7c 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md -README.md: 12ea48aa2f321deec82ecea14c42aad7362523d6 -README.zh.md: a720916f5c9eab005a056415455e5017c0d08f0d +README.md: 49ea5147b12f382f37fa550455035f948dec4fe8 +README.zh.md: 427eb4be339f6ad414fc84b7824445edaf934c54 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 12ea48aa2f..49ea5147b1 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -29,7 +29,7 @@ Users switch the color scheme and content font size from two rows in Settings (G ### Appearance and font size -The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 17 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; tables, small text, and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local. +The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 17 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local. ### Registering a theme @@ -53,7 +53,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou `src/styles/` holds five sheets imported in order by ui-theme's dynamic client entry: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. -`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. Dense table, small, and code variants stay fixed. The user bubble and composer draft consume the same variables outside that ladder. +`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small and code variants stay fixed. The user bubble, the composer draft, and flow-row titles and summaries consume the same variables outside that ladder. ### Scrollbar rebinding diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index a720916f5c..427eb4be33 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 外观与字号 -插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 17 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;表格、小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。 +插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 17 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。 ### 注册主题 @@ -53,7 +53,7 @@ kind: "package-reference" `src/styles/` 下有五张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR 会随 ui-theme 一同移除。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 -`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。紧凑的表格、小号文本与代码变体保持固定字号。用户气泡与 composer 草稿在该阶梯之外消费相同变量。 +`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本与代码变体保持固定字号。用户气泡、composer 草稿与流内行的标题及摘要在该阶梯之外消费相同变量。 ### 滚动条重新绑定 diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index c286c6d2e2..a4910e9a3d 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -22,10 +22,15 @@ body[data-ds-dark-theme] { 重新从 Figma 导出时需要保留这一缩放。 */ /* 正文字号调节:--dsh-content-font-size 由主题 bootstrap 与 ThemePresenter 写在 body 上(整数 px,12–17,默认 14)。标题与正文变体按同一像素增量 - (--dsh-content-font-delta)随动,保持层级;表格、small、code 属于密集 - 次级文本,保持固定。 */ + (--dsh-content-font-delta)随动,保持层级。低一档文本(think、工具/bash + 行的标题与摘要、markdown 表格、流内次级说明等)统一读 + --dsh-content-font-size-secondary:设置 ≤14 时为设置值 −1,>14 时为 + 设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15);small、code + 属于密集次级文本,保持固定。 */ body { --dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px); + --dsh-content-font-size-secondary: min(calc(var(--dsh-content-font-size, 14px) - 1px), max(13px, calc(var(--dsh-content-font-size, 14px) - 2px))); + --dsh-content-font-delta-secondary: calc(var(--dsh-content-font-size-secondary) - 13px); --dsw-font-markdown-h1: 700 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-h1-font-family: var(--dsw-font-family); @@ -83,18 +88,18 @@ body { --dsw-font-markdown-base-strong-italic-font-size: var(--dsh-content-font-size, 14px); --dsw-font-markdown-base-strong-italic-font-style: italic; - --dsw-font-markdown-table: 13px/22px var(--dsw-font-family); + --dsw-font-markdown-table: var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family); --dsw-font-markdown-table-font-family: var(--dsw-font-family); --dsw-font-markdown-table-font-weight: 400; - --dsw-font-markdown-table-line-height: 22px; - --dsw-font-markdown-table-font-size: 13px; + --dsw-font-markdown-table-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px)); + --dsw-font-markdown-table-font-size: var(--dsh-content-font-size-secondary, 13px); --dsw-font-markdown-table-font-style: normal; - --dsw-font-markdown-table-head: 500 13px/22px var(--dsw-font-family); + --dsw-font-markdown-table-head: 500 var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family); --dsw-font-markdown-table-head-font-family: var(--dsw-font-family); --dsw-font-markdown-table-head-font-weight: 500; - --dsw-font-markdown-table-head-line-height: 22px; - --dsw-font-markdown-table-head-font-size: 13px; + --dsw-font-markdown-table-head-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px)); + --dsw-font-markdown-table-head-font-size: var(--dsh-content-font-size-secondary, 13px); --dsw-font-markdown-table-head-font-style: normal; --dsw-font-markdown-small: 12px/20px var(--dsw-font-family); diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 7deef20ea8..4a3d5e46ef 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -1,8 +1,10 @@ /* Tool summary row (figma 122:9479): 24px single line at the default size — - [16 leading] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. + [16 leading] gap6 [title 13/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. The Settings font-size preference rides the body-published axis: the shared - DisclosureRow chrome scales the row height, title, and leading glyph; the - summary texts here follow the same size and px delta. */ + DisclosureRow chrome scales the row height and leading glyph by the body px + delta; the title and the summary texts here read the secondary tier + (--dsh-content-font-size-secondary: one step under the body — setting −1 at + ≤14, setting −2 above), matching think text. */ .root { display: flex; @@ -82,7 +84,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); } @@ -96,7 +98,7 @@ flex: none; margin-left: 4px; white-space: nowrap; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); } @@ -114,7 +116,7 @@ background: none; font: inherit; text-align: left; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); text-decoration: underline; diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index fbbab8cbbc..4c1d7b18b0 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -170,7 +170,7 @@ .title { flex: none; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); } @@ -190,7 +190,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts index 1a48bbdad8..28913a2939 100644 --- a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts +++ b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts @@ -43,12 +43,13 @@ describe('ToolRow.module.css summary line', () => { expect(declarations('.summarySuffix')).not.toEqual(expect.arrayContaining(['text-overflow: ellipsis'])) }) - it('sizes the summary texts from the content font axis', () => { + it('sizes the summary texts from the secondary content tier', () => { // The Settings font-size preference must reach tool-call rows, not only - // the narration body: summary, suffix, and file link ride the axis. + // the narration body: summary, suffix, and file link read the secondary + // tier (one step under the body), matching think text. for (const selector of ['.summary', '.summarySuffix', '.fileLink']) { expect(declarations(selector)).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index b7678eea03..6dd616bcf2 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -37,7 +37,7 @@ flex: none; max-width: 42%; color: var(--dsw-alias-label-secondary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); font-weight: 510; line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; @@ -49,8 +49,8 @@ flex: 1; min-width: 0; color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; white-space: nowrap; } @@ -102,7 +102,7 @@ min-width: 0; max-width: 42%; color: var(--dsw-alias-label-secondary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; @@ -113,8 +113,8 @@ flex: 1; min-width: 0; color: var(--dsw-alias-label-tertiary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; white-space: nowrap; } @@ -124,10 +124,10 @@ flex: none; /* Column width scales with the text (~132/13 ≈ 10px per font-size px) so larger sizes keep the same status text visible instead of truncating. */ - width: calc(132px + var(--dsh-content-font-delta, 0px) * 10); + width: calc(132px + var(--dsh-content-font-delta-secondary, 0px) * 10); color: var(--dsw-alias-label-secondary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-align: right; text-overflow: ellipsis; white-space: nowrap; @@ -234,8 +234,8 @@ overflow: hidden; width: 64px; color: var(--dsw-alias-label-secondary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-align: right; text-overflow: ellipsis; white-space: nowrap; @@ -243,8 +243,8 @@ .empty { color: var(--dsw-alias-label-tertiary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); padding: 0; } diff --git a/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts b/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts index 4530bee2f5..375fb46dae 100644 --- a/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts +++ b/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts @@ -31,18 +31,18 @@ describe('WorkflowRunPanel.module.css font-size axis', () => { ])) }) - it('member status and the empty placeholder keep their 1px step under the body', () => { + it('member status and the empty placeholder read the secondary tier', () => { for (const selector of ['.memberStatus', '.empty']) { expect(declarations(selector)).toEqual(expect.arrayContaining([ - 'font-size: calc(13px + var(--dsh-content-font-delta, 0px))', - 'line-height: calc(20px + var(--dsh-content-font-delta, 0px))', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px))', ])) } }) it('the phase status column widens with the text so larger sizes do not truncate', () => { expect(declarations('.phaseStatus')).toEqual(expect.arrayContaining([ - 'width: calc(132px + var(--dsh-content-font-delta, 0px) * 10)', + 'width: calc(132px + var(--dsh-content-font-delta-secondary, 0px) * 10)', ])) }) }) From 9e33469913b2c41a0fa645bf104384cd5ee99935 Mon Sep 17 00:00:00 2001 From: "yx.zhang" Date: Wed, 26 Aug 2026 17:52:57 +0800 Subject: [PATCH 02/18] fix(review): correct the secondary-tier floor claim and pin engine-resolved sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent Note (both languages), the PR prose, and the commit message claimed a 13px floor for the table variants; the formula has none — max(13px, setting − 2px) selects the −1 branch at low settings rather than clamping the result, so the tier bottoms out at 11px at the 12px setting, matching think text. Rewrite the claim, say so in the axis comment, and split the README sentence that lumped body-pair and secondary-pair consumers together. Assert the engine-resolved secondary size in the settings-chrome e2e (13px at the default, 13px at the 15px boundary, 14px at 16px, unchanged across reload), sync the StatsLine and workflow-panel spec headers with the tier they now pin, and note why memberLabel stays at the body size. --- ...6-08-18-settings-font-size-control.i18n.yaml | 4 ++-- .../2026-08-18-settings-font-size-control.md | 2 +- .../2026-08-18-settings-font-size-control.zh.md | 2 +- apps/web/tests/settings-chrome.e2e.ts | 17 +++++++++++++++++ .../src/client/chat/StatsLine.module.css | 5 +++-- packages/client/ui-theme/README.i18n.yaml | 4 ++-- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../src/styles/gradient-shadow-text.css | 5 +++-- .../src/client/WorkflowRunPanel.module.css | 3 +++ .../tests/panel-styles.client.spec.ts | 6 ++++-- 11 files changed, 38 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml index a540a7aa9c..f0999e1aa3 100644 --- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md -2026-08-18-settings-font-size-control.md: 9e8b933653359bd45abf34e45fec5497289435ec -2026-08-18-settings-font-size-control.zh.md: cff574bd5009db6d4d855384c10d6f9a034ea343 +2026-08-18-settings-font-size-control.md: 778893a2c0943f1d3af8905a27efb526071e073b +2026-08-18-settings-font-size-control.zh.md: 71ff3b556622e7091891f8e18f5eefcbda2b0851 diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md index 9e8b933653..778893a2c0 100644 --- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md @@ -24,7 +24,7 @@ The conversation's body text size was fixed (14px after the 0.875 markdown-ladde **Scaling via a multiplier (`em`/percentage) instead of a px delta.** Rejected: multiplying spreads the 12–17px range disproportionately across the ladder (21px h1 would swing ~18–25.5px) and produces fractional line heights; the fixed px shift keeps every step integer and the hierarchy's px gaps intact. -**Scaling every font token (small, code).** Rejected: those variants are dense by design; at −2 the small ladder would hit 10px and code 9px, below legibility. The table variants instead join the secondary tier, whose 13px floor keeps them legible at every setting. +**Scaling every font token (small, code).** Rejected: those variants are dense by design; at −2 the small ladder would hit 10px and code 9px, below legibility. The table variants instead join the secondary tier, bottoming out at 11px at the 12px setting — the same size think text reaches there. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md index cff574bd50..71ff3b5566 100644 --- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md @@ -24,7 +24,7 @@ **用倍率(`em`/百分比)而非像素增量缩放。**否决:乘法会让 12–17px 的范围在阶梯上不成比例地放大(21px 的 h1 会摆动到约 18–25.5px),并产生小数行高;固定像素平移让每一档都是整数,层级间的像素差保持不变。 -**缩放全部字体 token(code、small)。**否决:这些变体按设计就是密集文本;−2 档时 small 阶梯会降到 10px、code 降到 9px,低于可读下限。表格变体改为并入低一档层级,其 13px 下限在任何设置下都保持可读。 +**缩放全部字体 token(code、small)。**否决:这些变体按设计就是密集文本;−2 档时 small 阶梯会降到 10px、code 降到 9px,低于可读下限。表格变体改为并入低一档层级,在 12px 设置下触底 11px——与 think 文本在该设置下相同。 ## 后果 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index b36508aa5e..425ff5d1ee 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -351,7 +351,19 @@ describe('web e2e: settings modal and General preferences', () => { const readFontSize = async (target: Page = page): Promise => await target.evaluate( () => document.body.style.getPropertyValue('--dsh-content-font-size'), ) + // The secondary tier resolved by the real engine: a probe element's + // font-size forces min/max/calc evaluation, which the CSS-text specs + // cannot exercise. Setting −1 at ≤14, setting −2 above. + const readSecondaryFontSize = async (): Promise => await page.evaluate(() => { + const probe = document.createElement('div') + probe.style.fontSize = 'var(--dsh-content-font-size-secondary, 13px)' + document.body.appendChild(probe) + const size = getComputedStyle(probe).fontSize + probe.remove() + return size + }) expect(await readFontSize()).toBe('14px') + expect(await readSecondaryFontSize()).toBe('13px') await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) @@ -360,9 +372,13 @@ describe('web e2e: settings modal and General preferences', () => { const increase = dialog.getByRole('button', { name: '增大字号' }) await increase.click() await dialog.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) + // 15 is the piecewise boundary: the secondary tier holds at 13px (−2) + // where the ≤14 branch would have given 14px (−1). + await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('13px') await increase.click() await dialog.getByText('16', { exact: true }).waitFor({ timeout: 5_000 }) await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px') + await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('14px') await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) .toMatch(/ui-theme:\n(?:\s+\w+: .*\n)*?\s+fontSize: 16/) await page.keyboard.press('Escape') @@ -375,6 +391,7 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px') + expect(await readSecondaryFontSize()).toBe('14px') // Restore the default for the specs that follow (and the dialog golden). await page.getByRole('button', { name: '设置', exact: true }).click() diff --git a/packages/client/ui-chat/src/client/chat/StatsLine.module.css b/packages/client/ui-chat/src/client/chat/StatsLine.module.css index 8d997d93ce..29c0bc3aab 100644 --- a/packages/client/ui-chat/src/client/chat/StatsLine.module.css +++ b/packages/client/ui-chat/src/client/chat/StatsLine.module.css @@ -1,5 +1,6 @@ -/* Session stats row: 12/20 tertiary text under the flow, aligned to the - shared message column axis (--dsh-chat-content-width). */ +/* Session stats row: 13/20 tertiary text (the secondary content tier) under + the flow, aligned to the shared message column axis + (--dsh-chat-content-width). */ .root { /* Block, not flex: text-overflow only elides a block's inline content, so diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 8af8bc0e7c..376749a4f8 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md -README.md: 49ea5147b12f382f37fa550455035f948dec4fe8 -README.zh.md: 427eb4be339f6ad414fc84b7824445edaf934c54 +README.md: 32ec60edb39b30afdb9b54804e62302cdc58e531 +README.zh.md: ab17910ab9cec4e1ff519af0922ee27034a6fa7c diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 49ea5147b1..32ec60edb3 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -53,7 +53,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou `src/styles/` holds five sheets imported in order by ui-theme's dynamic client entry: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. -`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small and code variants stay fixed. The user bubble, the composer draft, and flow-row titles and summaries consume the same variables outside that ladder. +`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small and code variants stay fixed. Outside the ladder, the user bubble and composer draft read the body pair directly, and flow-row titles and summaries read the secondary pair. ### Scrollbar rebinding diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 427eb4be33..ab17910ab9 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -53,7 +53,7 @@ kind: "package-reference" `src/styles/` 下有五张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR 会随 ui-theme 一同移除。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 -`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本与代码变体保持固定字号。用户气泡、composer 草稿与流内行的标题及摘要在该阶梯之外消费相同变量。 +`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本与代码变体保持固定字号。阶梯之外,用户气泡与 composer 草稿直接读取正文档变量对,流内行的标题及摘要读取低一档变量对。 ### 滚动条重新绑定 diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index a4910e9a3d..a2d2597d89 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -25,8 +25,9 @@ body[data-ds-dark-theme] { (--dsh-content-font-delta)随动,保持层级。低一档文本(think、工具/bash 行的标题与摘要、markdown 表格、流内次级说明等)统一读 --dsh-content-font-size-secondary:设置 ≤14 时为设置值 −1,>14 时为 - 设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15);small、code - 属于密集次级文本,保持固定。 */ + 设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15)。式中 + max(13px,·) 的作用是让低设置取 −1 而非 −2,不是结果下限——12 设置下 + 层级触底 11px。small、code 属于密集次级文本,保持固定。 */ body { --dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px); --dsh-content-font-size-secondary: min(calc(var(--dsh-content-font-size, 14px) - 1px), max(13px, calc(var(--dsh-content-font-size, 14px) - 2px))); diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 6dd616bcf2..aa4ff56a3d 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -223,6 +223,9 @@ flex: 1; min-width: 0; color: var(--dsw-alias-label-secondary); + /* Body size, not the secondary tier: the member label is the panel's + content (an agent's task), while the run/phase headers, counts, and + statuses around it are chrome one step down. */ font-size: var(--dsh-content-font-size, 14px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; diff --git a/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts b/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts index 375fb46dae..0095a47bbf 100644 --- a/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts +++ b/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts @@ -1,8 +1,10 @@ /** * WorkflowRunPanel's font-size-axis adoption as CSS text. jsdom has no * layout, so these read the declarations that make the run/phase headers and - * the expanded member rows follow the Settings font-size preference through - * --dsh-content-font-size / --dsh-content-font-delta. + * the expanded member rows follow the Settings font-size preference: member + * labels at the body size (--dsh-content-font-size / --dsh-content-font-delta), + * the chrome around them on the secondary tier + * (--dsh-content-font-size-secondary / --dsh-content-font-delta-secondary). */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' From 0114dc1f817b7d5f594b19db1041301de3a47aac Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 26 Aug 2026 21:17:46 +0800 Subject: [PATCH 03/18] feat(web): polish the input trigger menu presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Candidate rows lead with domain icons instead of localized text prefixes; pointer and keyboard share one reducer-owned highlight (last input wins); drillable folder rows reveal a localized Browse-folder + Tab keycap hint with the library chevron; pending sources render skeleton bars; the menu spans the composer card. The editable @dir/ text decorates color-only — the domain icon now belongs exclusively to the settled reference chip. Composer placeholders advertise / and @, and the zh copy for commands is unified to 指令. --- ...trigger-menu-presentation-polish.i18n.yaml | 6 ++ ...26-web-trigger-menu-presentation-polish.md | 35 ++++++++ ...web-trigger-menu-presentation-polish.zh.md | 35 ++++++++ apps/web/tests/agent-preset-selection.e2e.ts | 5 ++ .../command-image-envelope.expected.e2e.ts | 2 +- .../conversation.expected.md | 2 +- .../goal-command-presentation/ui.expected.md | 2 +- .../markdown-cjk-strong/ui.expected.md | 2 +- .../expected/markdown-images/ui.expected.md | 2 +- .../markdown-inline-code-links/ui.expected.md | 2 +- .../expected/math-rendering/ui.expected.md | 2 +- .../reference-composer/menu.expected.md | 15 ++-- .../reference-composer/order.expected.md | 2 +- .../expected/skill-user-invoke/ui.expected.md | 2 +- .../stats-paged-history/ui.expected.md | 2 +- .../expected/steer-all/mid-steer.expected.md | 2 +- .../expected/steer-all/settled.expected.md | 2 +- apps/web/tests/goal-bar.e2e.ts | 2 +- apps/web/tests/image-display.expected.e2e.ts | 6 +- apps/web/tests/preview-boot.e2e.ts | 2 +- apps/web/tests/reference-composer.e2e.ts | 26 +++--- .../mid-stream.expected.md | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- apps/web/tests/subagent-conversation.e2e.ts | 6 +- apps/web/tests/subagent-interrupt-ui.e2e.ts | 2 +- apps/web/tests/support.ts | 4 +- packages/client/ui-chat/src/client/locale.ts | 4 +- .../ui-chat/tests/chat-view.client.spec.tsx | 4 +- .../src/client/input/decorations.ts | 4 +- .../input/editor/composer-editor.module.css | 19 +---- .../src/client/input/editor/text-ref.ts | 44 +++------- .../ui-conversation/src/client/locales.ts | 10 +-- .../tests/input-bar.client.spec.tsx | 32 +++---- .../tests/input-matrix.client.spec.tsx | 2 +- .../tests/submit-machine.client.spec.ts | 2 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 6 +- packages/client/ui-goal/src/client/locales.ts | 2 +- .../tests/goal-command-input.client.spec.tsx | 2 +- .../src/client/MenuView.module.css | 83 +++++++++++++++---- .../ui-input-trigger/src/client/MenuView.tsx | 55 ++++++++---- .../ui-input-trigger/src/client/controller.ts | 12 +++ .../ui-input-trigger/src/client/index.ts | 1 + .../ui-input-trigger/src/client/locales.ts | 6 +- .../ui-input-trigger/src/client/slots.ts | 7 ++ .../ui-input-trigger/src/core/contract.ts | 1 + .../client/ui-input-trigger/src/core/menu.ts | 11 ++- packages/client/ui-input-trigger/src/types.ts | 5 +- .../tests/apply.client.spec.ts | 2 +- .../tests/core-menu.client.spec.ts | 38 +++++++-- .../tests/menu-view.client.spec.tsx | 46 ++++++---- .../tests/service.client.spec.ts | 12 +++ .../client/ui-reference/src/client/index.ts | 6 +- .../client/ui-reference/src/client/locales.ts | 10 +-- .../tests/browser-plugin.client.spec.ts | 19 +++-- snapshots/web/approval-composer/session.jsonl | 2 +- snapshots/web/bash-abort-row/ui.expected.md | 2 +- snapshots/web/code-mode-round/session.jsonl | 8 +- snapshots/web/code-mode-round/ui.expected.md | 2 +- snapshots/web/cordis-tool-round/session.jsonl | 2 +- .../web/cordis-tool-round/ui.expected.md | 2 +- .../web/feedback-command/ack.expected.md | 2 +- snapshots/web/feedback-command/session.jsonl | 6 +- snapshots/web/fresh-round-trip/session.jsonl | 8 +- snapshots/web/fresh-round-trip/ui.expected.md | 2 +- .../web/goal-multi-turn-actions/session.jsonl | 16 ++-- .../goal-multi-turn-actions/ui.expected.md | 2 +- .../web/lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/plan-active.expected.md | 2 +- .../web/lifecycle-chrome/reloaded.expected.md | 2 +- snapshots/web/lifecycle-chrome/session.jsonl | 6 +- .../web/live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../web/live-interactions/loading.expected.md | 2 +- .../retry-exhausted.expected.md | 2 +- .../web/live-interactions/retry.expected.md | 2 +- .../running-draft.expected.md | 2 +- snapshots/web/live-interactions/session.jsonl | 6 +- snapshots/web/message-actions/ui.expected.md | 2 +- snapshots/web/minimal-preset/session.jsonl | 2 +- snapshots/web/minimal-preset/ui.expected.md | 2 +- .../permission-policy-context/session.jsonl | 28 +++---- .../web/plan-review/approved.expected.md | 2 +- snapshots/web/plan-review/session.jsonl | 10 +-- .../question-composer/answered.expected.md | 2 +- snapshots/web/question-composer/session.jsonl | 8 +- .../web/queue-actions/preserved.expected.md | 2 +- .../seeded-history/command-row.expected.md | 2 +- .../seeded-history/feedback-row.expected.md | 2 +- snapshots/web/seeded-history/ui.expected.md | 2 +- snapshots/web/skill-tool-row/ui.expected.md | 2 +- snapshots/web/steering/session.jsonl | 2 +- snapshots/web/steering/settled.expected.md | 2 +- .../web/subagent-conversation/ui.expected.md | 2 +- .../web/turn-tail-actions/running.expected.md | 2 +- snapshots/web/turn-tail-actions/session.jsonl | 8 +- .../web/turn-tail-actions/settled.expected.md | 2 +- .../usage-expanded.expected.md | 2 +- snapshots/web/web-search-round/session.jsonl | 2 +- snapshots/web/web-search-round/ui.expected.md | 2 +- 101 files changed, 498 insertions(+), 299 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md create mode 100644 .agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml new file mode 100644 index 0000000000..973b2da030 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md +2026-08-26-web-trigger-menu-presentation-polish.md: 62582794cf46719a4ede3627164b4dda8c1d372d +2026-08-26-web-trigger-menu-presentation-polish.zh.md: 627f0fb2ce152bf3a505cea892eeda484fcd29e4 diff --git a/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md new file mode 100644 index 0000000000..62582794cf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md @@ -0,0 +1,35 @@ +# Agent Note: Web trigger menu presentation polish + +Status: implemented + +English | [中文](2026-08-26-web-trigger-menu-presentation-polish.zh.md) + +## Problem + +The Web composer's `/` and `@` trigger menu carried several presentation defects that made the reference flow harder to read and operate. Candidate rows spelled their kind as a localized text prefix (`Folder · name/`, `Session · label`) that duplicated the section title and pushed the name right. Pointer hover used a CSS `:hover` tint while keyboard navigation drove the reducer-owned highlight, so two rows could look focused at once. The drillable-folder affordance was a raw `›` text glyph, unlike every other chevron in the composer, and nothing told the user that Tab drills into the highlighted folder. The pending-source state was a bare "Loading…" text row. The editable `@dir/` text a drill leaves behind rendered a folder icon before the `@`, visually double-marking a token that is not a settled chip. The composer placeholders never mentioned that `/` and `@` exist ([#3080](https://github.com/deepseek-harness/deepseek-harness/issues/3080)). + +## Decision + +Candidate rows lead with a domain icon instead of a text prefix: `InputTriggerCandidate.icon` narrows from `string` to the closed union `InputTriggerCandidateIcon` (`file | folder | session`), the menu view maps it to `ReferenceIcon`, and `ui-reference` emits bare names (`folderx/`, session label). The `candidate.file`/`candidate.folder`/`candidate.session` locale keys are deleted; the session section title is `对话`/`Sessions`. The menu spans the composer card edge to edge (`left: 0; right: 0`), and a pending source renders two breathing skeleton bars in item cell metrics instead of the loading text row. + +Pointer and keyboard share one highlight, last input wins: a `hover` MenuEvent parks the reducer-owned highlight on a ready row, `MenuView` routes it from `onMouseMove` (not `mouseenter`, so keyboard-scrolling rows under a resting pointer cannot steal the highlight back), and the CSS `:hover` tint is gone. + +The drill affordance on the highlighted folder row is the library `IconChevronRightOutline14` in the quiet `--dsw-alias-label-caption` tint the access-mode chevron uses, preceded by a localized "Browse folder" caption and a `Tab` keycap that reveal only while the row holds the shared highlight. + +A token still carrying its trigger character is editable text, not a settled chip: the text-ref decoration colors it and nothing more, and the domain icon belongs exclusively to the settled `ReferenceChipNode`. The former appearance channel (scan `appearance` field, `TextRefNode.__appearance`, `data-ref-appearance` DOM attribute, CSS `::before` icon) is deleted end to end. + +Composer placeholders advertise both triggers (`描述你想要构建的内容… / 调用指令 @ 文件或会话` / `Describe what you want to build... / commands, @ files or sessions`), and the zh copy for commands is unified from 命令 to 指令 across `ui-chat`, `ui-conversation`, `ui-goal`, and `ui-input-trigger`. + +## Alternatives considered + +**Keep the CSS `:hover` tint alongside the keyboard highlight.** Rejected: two rows can look focused at once while `aria-activedescendant` names only one, and Enter acts on the keyboard row while the eye may rest on the hovered one. + +**Route hover from `mouseenter`.** Rejected: when arrow keys scroll new rows under a stationary pointer, each row entering the pointer re-fires `mouseenter` and steals the highlight the user just moved; `mousemove` fires only on real pointer motion. + +**Keep the folder icon on the editable `@dir/` text.** Rejected: the icon before the trigger character double-marks the token and erases the visual distinction between "still editable text" and "settled chip"; reserving the icon for the chip makes the two states readable at a glance. + +**Show the Tab hint on every drillable row.** Rejected: idle rows carrying persistent keycaps add noise; the hint teaches the key exactly when it applies — while that row is the one Tab would act on. + +## Consequences + +The kind information every row used to spell in text now rides the icon and section title; a future candidate kind must extend `InputTriggerCandidateIcon` and pick an icon rather than pass an arbitrary string. Pointer motion round-trips through the reducer (`hover` is a no-op for the already-highlighted row, so mousemove storms do not churn state). Drill discoverability rests on the highlight: an idle folder row shows only its chevron until hovered or reached by keys. Deferred follow-ups — settle-on-space for exact-match tokens, candidate description content, back navigation after a drill, `name` vs `name/` labels, and reference search latency — are tracked in [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154). diff --git a/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md new file mode 100644 index 0000000000..627f0fb2ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md @@ -0,0 +1,35 @@ +# Agent Note:Web 触发菜单呈现打磨 + +Status: implemented + +[English](2026-08-26-web-trigger-menu-presentation-polish.md) | 中文 + +## Problem + +Web composer 的 `/` 与 `@` 触发菜单存在多处呈现缺陷,使引用流程更难阅读和操作。候选行用本地化文字前缀标注类型(`Folder · name/`、`Session · label`),既与 section 标题重复又把名称挤向右侧。指针悬停用 CSS `:hover` 着色,而键盘导航驱动 reducer 持有的高亮,两行可能同时呈现焦点态。可下钻文件夹的操作标记是裸文本 `›`,与 composer 中其他 chevron 不一致,且没有任何提示告诉用户 Tab 可以下钻高亮的文件夹。来源加载中状态是一行"正在加载…"文字。下钻留下的可编辑 `@dir/` 文本在 `@` 前渲染文件夹图标,对一个并非 settled chip 的 token 形成视觉双重标记。composer 的 placeholder 从未提及 `/` 和 `@` 的存在([#3080](https://github.com/deepseek-harness/deepseek-harness/issues/3080))。 + +## Decision + +候选行以领域图标开头,不再用文字前缀:`InputTriggerCandidate.icon` 从 `string` 收窄为封闭联合 `InputTriggerCandidateIcon`(`file | folder | session`),菜单视图将其映射到 `ReferenceIcon`,`ui-reference` 只输出裸名称(`folderx/`、session 标签)。删除 `candidate.file`/`candidate.folder`/`candidate.session` 三个 locale 键;session section 标题改为 `对话`/`Sessions`。菜单与 composer 卡片左右等宽(`left: 0; right: 0`),加载中的来源渲染两条与候选行同尺寸的呼吸骨架条,替代加载文字行。 + +指针与键盘共享单一高亮,后到者优先:新增 `hover` MenuEvent 把 reducer 持有的高亮停在某个就绪行上,`MenuView` 从 `onMouseMove` 路由(不用 `mouseenter`,避免键盘滚动把新行送到静止指针下时抢回高亮),CSS `:hover` 着色整体移除。 + +高亮文件夹行上的下钻标记改为库内 `IconChevronRightOutline14`,使用访问模式 chevron 同款的弱色 `--dsw-alias-label-caption`,左侧为本地化"进入目录"文字加 `Tab` 键帽提示,仅当该行持有共享高亮时显示。 + +仍带触发符的 token 是可编辑文本而非 settled chip:text-ref 装饰只做染色,领域图标专属于 settled 的 `ReferenceChipNode`。原有的 appearance 通道(扫描的 `appearance` 字段、`TextRefNode.__appearance`、`data-ref-appearance` DOM 属性、CSS `::before` 图标)端到端删除。 + +composer placeholder 同时提示两个触发符(`描述你想要构建的内容… / 调用指令 @ 文件或会话` / `Describe what you want to build... / commands, @ files or sessions`),并将 `ui-chat`、`ui-conversation`、`ui-goal`、`ui-input-trigger` 中命令的中文文案统一为"指令"。 + +## Alternatives considered + +**保留 CSS `:hover` 着色与键盘高亮并存。** 被否:两行可能同时呈现焦点态,而 `aria-activedescendant` 只指向一行;Enter 作用于键盘行,视线却可能停在悬停行上。 + +**从 `mouseenter` 路由悬停。** 被否:方向键把新行滚动到静止指针下方时,每个进入指针的行都会重新触发 `mouseenter`,抢走用户刚移走的高亮;`mousemove` 只在指针真实移动时触发。 + +**保留可编辑 `@dir/` 文本上的文件夹图标。** 被否:触发符前的图标对 token 形成双重标记,抹掉了"仍可编辑的文本"与"settled chip"之间的视觉区分;图标专属于 chip 才能让两种状态一眼可辨。 + +**在所有可下钻行上常驻 Tab 提示。** 被否:空闲行常驻键帽增加噪音;提示恰好在其生效时出现——该行正是 Tab 将作用的行。 + +## Consequences + +过去每行用文字拼写的类型信息现在由图标和 section 标题承载;未来新增候选类型必须扩展 `InputTriggerCandidateIcon` 并选定图标,而非传任意字符串。指针移动经 reducer 往返(`hover` 对已高亮行是 no-op,mousemove 风暴不会搅动状态)。下钻的可发现性依赖高亮:空闲文件夹行在被悬停或键盘到达前只显示 chevron。延后的跟进项——精确匹配 token 的空格 settle、候选 description 内容、下钻后的回退导航、`name` 与 `name/` 标签、引用搜索延迟——记录在 [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154)。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 4c0cd69705..ec118a1ed9 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -256,6 +256,10 @@ describe('web e2e: agent-preset selection', () => { // remains outside every preset. expect(onMinimal.some(option => option.startsWith('goal'))).toBe(false) expect(onMinimal.some(option => option.startsWith('model'))).toBe(true) + // Escape closes the menu before clearing: Playwright's fill('') leaves no + // caret, so trigger tracking never sees the emptied draft, and the open + // full-width menu would swallow the preset-seat click below. + await page.keyboard.press('Escape') await composer.fill('') // Switching back up reaches the host at all — the chip compares the pick @@ -273,6 +277,7 @@ describe('web e2e: agent-preset selection', () => { expect(onStandard.some(option => option.startsWith('compact'))).toBe(true) expect(onStandard.some(option => option.startsWith('goal'))).toBe(true) expect(onStandard.some(option => option.startsWith('plan'))).toBe(true) + await page.keyboard.press('Escape') await composer.fill('') }, 90_000) diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index cc0b7a01a2..e378072db1 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -21,7 +21,7 @@ async function freshComposer(): Promise { fireEvent.click(start) return await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 738a1b11a3..d30b4a4e1e 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -42,7 +42,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index 42811d29ae..c10eef6aac 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -14,7 +14,7 @@ - img - img - text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]" -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index adae2f723e..65c73793b2 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -45,7 +45,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 85c537bb18..3c6a9475f8 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -24,7 +24,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index d940beabc9..228443ee1e 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -36,7 +36,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 5561c3574e..e063215b77 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -40,7 +40,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/reference-composer/menu.expected.md b/apps/web/tests/expected/reference-composer/menu.expected.md index 6668011066..0a2fa81c19 100644 --- a/apps/web/tests/expected/reference-composer/menu.expected.md +++ b/apps/web/tests/expected/reference-composer/menu.expected.md @@ -1,9 +1,10 @@ - listbox "Trigger suggestions": - text: Files & folders - - option "Folder · folderx/ folderx Browse folder" [selected]: - - text: Folder · folderx/ folderx - - button "Browse folder": › - - option "File · reference.txt reference.txt" - - text: Session conversations - - option "Session · Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}" - - option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}" + - option "folderx/ folderx Browse folder" [selected]: + - text: folderx/ folderx + - button "Browse folder": + - img + - option "reference.txt reference.txt" + - text: Sessions + - option "Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}" + - option "Research notes reference-source-session · {{cwd}} · {{timestamp}}" diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 75543314b2..7cec2eaf08 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -13,7 +13,7 @@ - button "Session recall Research notes": - img - text: Session recall Research notes -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index b06b3aae82..54ad367774 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -34,7 +34,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 1722b90b13..dc2200a0b3 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -375,7 +375,7 @@ - text: 7/25 {{clock}} Ran for {{duration}} - button "Back to bottom": - img -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index c201520e8d..c3847df394 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -31,7 +31,7 @@ - text: "Interjection: include the word ORANGE in your final reply." - button "Copy": - img -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 885143a0f6..2bff42b96c 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -44,7 +44,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index 06bc22a74b..b9c7695066 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -45,7 +45,7 @@ describe('web e2e: goal bar clear convergence', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear')) // Startup reuses the fixture workspace's blank session, keeping this // command independent of alpha's running replay and pending question. - const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]') + const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') await input.waitFor({ timeout: 10_000 }) await input.fill('/goal guard rapid clear clicks') await input.press('Enter') diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index 6c8107d1bd..37be0f24ac 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -90,7 +90,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy // this assembled lane pins the intake chain over the built graph. const textarea = await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface @@ -165,7 +165,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov fireEvent.click(start) const textarea = await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface @@ -220,7 +220,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( const textarea = await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 0d054a293d..37c8e48fec 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -310,7 +310,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { const configureLater = page.getByRole('button', { name: 'Configure later' }) await configureLater.waitFor({ timeout: 30_000 }) await configureLater.click() - await page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') .waitFor({ timeout: 30_000 }) const exercised = await page.evaluate(async () => { diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 3f170b6558..c3d80722e5 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -150,14 +150,14 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Files & folders') - expect(snapshot).toContain('Session conversations') + expect(snapshot).toContain('Sessions') expect(snapshot).not.toContain('text: reference Files & folders') - expect(snapshot).toContain('File \u00b7 reference.txt') - expect(snapshot).toContain('Session \u00b7 Research notes') + expect(snapshot).toContain('reference.txt') + expect(snapshot).toContain('Research notes') expect(snapshot).not.toContain('text: Subagents') await input.fill('@reference') - await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click() + await menu.getByRole('option', { name: /reference\.txt/ }).click() // The pick lands an atomic chip: a real DOM capsule carrying the domain // icon and the label (the canonical reference text lives on the node and // expands on submit; the surface text is the label plus the separator). @@ -167,7 +167,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await expect.poll(() => input.textContent()).toBe('reference.txt ') await input.fill('@Research') - await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click() + await menu.getByRole('option', { name: /Research notes/ }).click() const sessionReference = page.locator('[data-composer-chip]').last() await expect.poll(() => sessionReference.textContent()).toBe('Research notes') await expect.poll(() => sessionReference.locator('svg').count()).toBe(1) @@ -183,7 +183,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await input.fill('@reference') - await menu.getByRole('option', { name: /File · reference\.txt/ }).click() + await menu.getByRole('option', { name: /reference\.txt/ }).click() await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) // The #2813 gesture: collapse the caret to the document start, directly @@ -192,7 +192,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await page.keyboard.press('ControlOrMeta+A') await page.keyboard.press('ArrowLeft') await page.keyboard.type('@Research') - await menu.getByRole('option', { name: /Session · Research notes/ }).click() + await menu.getByRole('option', { name: /Research notes/ }).click() // Both chips survive the boundary insert: the session chip lands ahead of // the intact file chip. @@ -212,7 +212,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await input.fill('@reference') - await menu.getByRole('option', { name: /File · reference\.txt/ }).click() + await menu.getByRole('option', { name: /reference\.txt/ }).click() await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) // First ArrowLeft crosses the trailing space; the second steps across the @@ -250,7 +250,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through // as an atomic chip — folder glyph, no trigger character, one unit. await writeComposerDraft(page, input, '@folderx') // First folder query on this page: allow the Host index a cold start. - await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor({ timeout: 60_000 }) + await menu.getByRole('option', { name: /^folderx\// }).waitFor({ timeout: 60_000 }) await page.keyboard.press('Enter') const chip = input.locator('[data-composer-chip]').last() await expect.poll(() => chip.textContent()).toBe('folderx/') @@ -260,18 +260,18 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through // Tab drills: the literal descent text stays editable and the open menu // lists the folder's children. await writeComposerDraft(page, input, '@folderx') - await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor() + await menu.getByRole('option', { name: /^folderx\// }).waitFor() await page.keyboard.press('Tab') await expect.poll(() => input.textContent()).toBe('@folderx/') - await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor() + await menu.getByRole('option', { name: /child\.txt/ }).waitFor() // The row chevron drills the same way by pointer. await writeComposerDraft(page, input, '@folderx') - const row = menu.getByRole('option', { name: /Folder · folderx\// }) + const row = menu.getByRole('option', { name: /^folderx\// }) await row.waitFor() await row.getByRole('button', { name: 'Browse folder' }).click() await expect.poll(() => input.textContent()).toBe('@folderx/') - await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor() + await menu.getByRole('option', { name: /child\.txt/ }).waitFor() await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index ebcb6163c7..cd696c5411 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -24,7 +24,7 @@ - button "Copy" - code: "const first: number = 1 const second = \"two\" let tail" - status: Deep diving... -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index e257aa031d..1404402b4f 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -130,7 +130,7 @@ describe('web e2e: startup auto-selection', () => { expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true) releaseOpening() - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) acknowledgeReloadConnectionLoss(tripwire, warningsBefore) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 147bf7ea50..ec554e3863 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -393,7 +393,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await page.locator('[data-composer-seat]').evaluate(element => getComputedStyle(element).visibility)).toBe('hidden') releaseCatalog() - const input = page.getByRole('textbox', { name: 'Message the agent' }) + const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }) await input.waitFor({ timeout: 15_000 }) await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true) acknowledgeReloadConnectionLoss(tripwire, warningStart) @@ -417,7 +417,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = resolveEnded() }) }) - const input = page.getByRole('textbox', { name: 'Message the agent' }) + const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }) await input.fill(FOLLOWUP) await input.press('Enter') await expect.poll( @@ -500,7 +500,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .click() await page.getByRole('button', { name: '3 subagents' }).hover() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() + await page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }).waitFor() const forkResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/fork') await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 1975e0b614..c6154c5177 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -259,7 +259,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co .getByRole('button').first().click() await page.getByRole('button', { name: /1 subagent/ }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - const input = page.getByRole('textbox', { name: 'Message the agent' }) + const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }) await input.waitFor({ timeout: 15_000 }) expect(await input.isDisabled()).toBe(false) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index b43fcbaa76..2794ae44bb 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -83,7 +83,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo await dialog.getByRole('button', { name: 'Open', exact: true }).click() // The pick connected the workspace: the blank session's live composer // replaces the locked placeholder and enables. - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) } @@ -106,7 +106,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容… / 调用指令 @ 文件或会话"]') .waitFor({ timeout: 15_000 }) } diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index a693a7760f..78321ef9f4 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -91,9 +91,9 @@ export const zh = { 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'command.running': '执行中…', - 'command.failed': '命令失败', + 'command.failed': '指令失败', 'command.done': '已完成', - 'command.title': '命令', + 'command.title': '指令', 'row.running': '运行中', 'row.failed': '失败', 'json.truncated': '… 已截断,共 {total} 字符', 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 1f200a2029..ec6af626ee 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1568,7 +1568,7 @@ describe('ChatView', () => { }) const fv = render() expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull() - expect(fv.getByText('命令失败')).toBeTruthy() + expect(fv.getByText('指令失败')).toBeTruthy() expect(fv.getByText('失败')).toBeTruthy() // Still executing: running state with the executing copy. @@ -1585,7 +1585,7 @@ describe('ChatView', () => { nodes: [command({ seq: 8, commandId: 'cmd-4' as CommandNode['commandId'], name: null, args: null, outcome: { kind: 'success' } })], }) const ov = render() - expect(ov.getByText('命令')).toBeTruthy() + expect(ov.getByText('指令')).toBeTruthy() expect(ov.getByText('已完成')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index 5716c8108f..e20a006dac 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -18,8 +18,6 @@ export interface TextRefRange { readonly start: number readonly end: number readonly trigger: '/' | '@' - /** Optional icon domain for syntax-recognizable plain references. */ - readonly appearance?: 'folder' } /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ @@ -59,7 +57,7 @@ export function scanTextRefs( const start = folder.index + (folder[1]?.length ?? 0) const end = start + token.length if (!out.some(range => range.start < end && range.end > start)) { - out.push({ start, end, trigger: '@', appearance: 'folder' }) + out.push({ start, end, trigger: '@' }) } } return out.sort((left, right) => left.start - right.start) diff --git a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css index 40f6f2d075..25b884b689 100644 --- a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css +++ b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css @@ -3,24 +3,11 @@ this sheet covers text-level decorations). */ /* Plain-text reference: chip family colors over the draft's own glyphs. - clone keeps rounded ends on soft-wrap fragments. */ + clone keeps rounded ends on soft-wrap fragments. Color only, no icon — + a token still carrying its trigger character is editable text; the domain + icon belongs to the settled chip alone. */ .textRef { color: var(--dsw-alias-state-business-primary); box-decoration-break: clone; -webkit-box-decoration-break: clone; } - -/* Folder references carry the domain glyph as an icon prefix (the bubble's - IconFolderClose16 asset as a currentcolor mask). The literal token text — - trigger character included — stays intact: a Lexical text node cannot - split out its trigger character the way the old backdrop overpainted it. */ -.textRef[data-ref-appearance='folder']::before { - display: inline-block; - width: 14px; - height: 14px; - margin-right: 2px; - vertical-align: -2px; - background-color: currentcolor; - content: ''; - mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cpath%20transform%3D%22translate(1.5%202.429)%22%20d%3D%22M5.05582%200.518756L4.50669%200.86654L5.05582%200.518756ZM13%209.4837L13.65%209.4837L13.65%203.53962L13%203.53962L12.35%203.53962L12.35%209.4837L13%209.4837ZM11.3264%201.86603L11.3264%201.21603L6.52313%201.21603L6.52313%201.86603L6.52313%202.51603L11.3264%202.51603L11.3264%201.86603ZM5.58054%201.34727L6.12968%200.999489L5.60495%200.170972L5.05582%200.518756L4.50669%200.86654L5.03141%201.69506L5.58054%201.34727ZM4.11323%201.23058e-13L4.11323%20-0.65L1.67359%20-0.65L1.67359%205.00699e-14L1.67359%200.65L4.11323%200.65L4.11323%201.23058e-13ZM0%201.67359L-0.65%201.67359L-0.65%209.4837L0%209.4837L0.65%209.4837L0.65%201.67359L0%201.67359ZM11.3264%2011.1573L11.3264%2010.5073L1.67359%2010.5073L1.67359%2011.1573L1.67359%2011.8073L11.3264%2011.8073L11.3264%2011.1573ZM0%209.4837L-0.65%209.4837C-0.65%2010.767%200.390308%2011.8073%201.67359%2011.8073L1.67359%2011.1573L1.67359%2010.5073C1.10828%2010.5073%200.65%2010.049%200.65%209.4837L0%209.4837ZM1.67359%205.00699e-14L1.67359%20-0.65C0.390307%20-0.65%20-0.65%200.390309%20-0.65%201.67359L0%201.67359L0.65%201.67359C0.65%201.10828%201.10828%200.65%201.67359%200.65L1.67359%205.00699e-14ZM5.05582%200.518756L5.60495%200.170972C5.28121%20-0.340193%204.71829%20-0.65%204.11323%20-0.65L4.11323%201.23058e-13L4.11323%200.65C4.27282%200.65%204.4213%200.731715%204.50669%200.86654L5.05582%200.518756ZM6.52313%201.86603L6.52313%201.21603C6.36354%201.21603%206.21507%201.13431%206.12968%200.999489L5.58054%201.34727L5.03141%201.69506C5.35515%202.20622%205.91808%202.51603%206.52313%202.51603L6.52313%201.86603ZM13%203.53962L13.65%203.53962C13.65%202.25634%2012.6097%201.21603%2011.3264%201.21603L11.3264%201.86603L11.3264%202.51603C11.8917%202.51603%2012.35%202.97431%2012.35%203.53962L13%203.53962ZM13%209.4837L12.35%209.4837C12.35%2010.049%2011.8917%2010.5073%2011.3264%2010.5073L11.3264%2011.1573L11.3264%2011.8073C12.6097%2011.8073%2013.65%2010.767%2013.65%209.4837L13%209.4837Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E") center / contain no-repeat; -} diff --git a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts index b229948eba..9f57e4c10e 100644 --- a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts +++ b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts @@ -3,11 +3,13 @@ * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): * a `/name` or `@name` token whose name is on the trigger's lexicon, and * syntax-recognizable `@dir/` folder tokens, render in the chip family - * colors. Pure derivation as before — the entity transform converts matching - * text into TextRefNode and back as edits move it in and out of match shape; - * no occurrence identity exists. + * colors. Color only, no icon: a token still carrying its trigger character + * is editable text, not a settled chip — the domain icon marks exactly the + * settled state. Pure derivation as before — the entity transform converts + * matching text into TextRefNode and back as edits move it in and out of + * match shape; no occurrence identity exists. */ -import type { EditorConfig, LexicalEditor, NodeKey, SerializedTextNode, Spread } from 'lexical' +import type { EditorConfig, LexicalEditor, SerializedTextNode } from 'lexical' import { TextNode } from 'lexical' import { registerLexicalTextEntity } from '@lexical/text' import { mergeRegister } from '@lexical/utils' @@ -16,15 +18,10 @@ import { scanTextRefs } from '../decorations.ts' import css from './composer-editor.module.css' /** JSON form of one text-ref node. */ -export type SerializedTextRefNode = Spread<{ - appearance?: 'folder' -}, SerializedTextNode> +export type SerializedTextRefNode = SerializedTextNode /** One matched plain-text reference as a styled, fully editable text node. */ export class TextRefNode extends TextNode { - /** Optional icon domain for syntax-recognizable plain references. */ - __appearance: 'folder' | undefined - /** Lexical node registry type tag. */ static override getType(): string { return 'composer-text-ref' @@ -36,7 +33,7 @@ export class TextRefNode extends TextNode { * @returns a copy carrying the same NodeKey. */ static override clone(node: TextRefNode): TextRefNode { - return new TextRefNode(node.__text, node.__appearance, node.__key) + return new TextRefNode(node.__text, node.__key) } /** @@ -45,7 +42,7 @@ export class TextRefNode extends TextNode { * @returns a fresh node. */ static override importJSON(json: SerializedTextRefNode): TextRefNode { - const node = new TextRefNode(json.text, json.appearance) + const node = new TextRefNode(json.text) node.setFormat(json.format) node.setDetail(json.detail) node.setMode(json.mode) @@ -53,22 +50,11 @@ export class TextRefNode extends TextNode { return node } - /** - * @param text - the matched token text. - * @param appearance - optional icon domain (folder tokens). - * @param key - Lexical clone-path key; absent for fresh nodes. - */ - constructor(text: string, appearance?: 'folder', key?: NodeKey) { - super(text, key) - this.__appearance = appearance - } - /** Serialize to the JSON node form. */ override exportJSON(): SerializedTextRefNode { return { ...super.exportJSON(), type: 'composer-text-ref', - ...(this.__appearance === undefined ? {} : { appearance: this.__appearance }), } } @@ -77,7 +63,6 @@ export class TextRefNode extends TextNode { const el = super.createDOM(config) el.classList.add(css.textRef ?? 'textRef') el.setAttribute('data-composer-text-ref', '') - if (this.__appearance !== undefined) el.setAttribute('data-ref-appearance', this.__appearance) return el } @@ -92,15 +77,6 @@ export class TextRefNode extends TextNode { } } -/** - * Folder-shape probe for one matched token (the appearance bit). - * @param token - matched token text. - * @returns 'folder' for `@dir/` shapes; undefined otherwise. - */ -function appearanceOf(token: string): 'folder' | undefined { - return token.startsWith('@') && token.endsWith('/') ? 'folder' : undefined -} - /** * Register the plain-text reference entity transform. The claim decoration * has precedence on the leading-token seat: while a command claim holds, the @@ -130,7 +106,7 @@ export function registerTextRefDecoration( editor, getMatch, TextRefNode, - node => new TextRefNode(node.getTextContent(), appearanceOf(node.getTextContent())), + node => new TextRefNode(node.getTextContent()), ), ) } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 3ab8410590..480f73764b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -13,13 +13,13 @@ export const zh = { 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', 'placeholder.plan': PLAN_NEXT_ACTION_ZH, - 'placeholder.default': '给智能体发消息', + 'placeholder.default': '发消息或做任务… / 调用指令 @ 文件或会话', 'placeholder.unavailable': '会话不可用', 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', - 'placeholder.hero': '描述你想要构建的内容', + 'placeholder.hero': '描述你想要构建的内容… / 调用指令 @ 文件或会话', 'placeholder.workspace': '选择一个工作区开始', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', - 'input.commands': '命令', + 'input.commands': '指令', 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', @@ -158,10 +158,10 @@ export const en = { 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', 'placeholder.plan': PLAN_NEXT_ACTION_EN, - 'placeholder.default': 'Message the agent', + 'placeholder.default': 'Message or run a task... / commands, @ files or sessions', 'placeholder.unavailable': 'Session unavailable', 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', - 'placeholder.hero': 'Describe what you want to build', + 'placeholder.hero': 'Describe what you want to build... / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.commands': 'Commands', diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 6fd24ce020..7fa6c189a5 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -427,9 +427,9 @@ describe('Enter semantics', () => { }) it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => { - expect(bench({ running: true }).placeholder).toBe('给智能体发消息') - expect(bench({ queue: [row('q-1')] }).placeholder).toBe('给智能体发消息') - expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('给智能体发消息') + expect(bench({ running: true }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') + expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') + expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') expect(bench({ running: true, queue: [row('q-1')], @@ -437,7 +437,7 @@ describe('Enter semantics', () => { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }, - }).placeholder).toBe('给智能体发消息') + }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') expect(bench({ running: true, queue: [row('q-1')], @@ -449,7 +449,7 @@ describe('Enter semantics', () => { running: true, queue: [row('q-1')], commandMenuOpen: true, - }).placeholder).toBe('给智能体发消息') + }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') // The steer hint intentionally outranks the plan placeholder: while it // shows, the whole-queue gesture is genuinely available in plan mode. expect(bench({ @@ -781,7 +781,7 @@ describe('running and lock semantics', () => { }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) expect(button.getAttribute('aria-label')).toBe('发送消息') expect(button.disabled).toBe(true) expect(interruptButton?.disabled).toBe(false) @@ -829,7 +829,7 @@ describe('running and lock semantics', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('会话不可用') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) }) it('idle primary sends and disables on empty draft', () => { @@ -945,7 +945,7 @@ describe('running and lock semantics', () => { it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { expect(bench({ disabled: true }).placeholder).toBe('会话不可用') const live = bench() - expect(live.placeholder).toBe('给智能体发消息') + expect(live.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') const custom = bench({ placeholder: 'Custom placeholder' }) expect(custom.placeholder).toBe('Custom placeholder') }) @@ -962,7 +962,7 @@ describe('running and lock semantics', () => { expect(editableOf(textarea)).toBe(false) expect(textarea.getAttribute('aria-haspopup')).toBe('menu') expect(textarea.getAttribute('aria-expanded')).toBe('false') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(textarea) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -992,7 +992,7 @@ describe('running and lock semantics', () => { expect(entering.placeholder).toBe('描述你的任务以生成计划') // Pending exit: target is default again. const leaving = bench({ plan: { active: true, pending: true } }) - expect(leaving.placeholder).toBe('给智能体发消息') + expect(leaving.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') // Owner placeholder outranks the plan swap. const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' }) expect(custom.placeholder).toBe('Custom placeholder') @@ -1153,12 +1153,14 @@ describe('decorations', () => { expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull() }) - it('a directory completion carries the folder appearance without changing its plain text', () => { + it('a directory completion decorates color-only: literal text, no icon seat', () => { const { view, shell } = bench() act(() => { shell.setDraft('see @src/components/') }) const mark = view.container.querySelector('[data-composer-text-ref]') expect(mark?.textContent).toBe('@src/components/') - expect(mark?.getAttribute('data-ref-appearance')).toBe('folder') + // The trigger character marks editable text; the domain icon is the + // settled chip's alone. + expect(mark?.getAttribute('data-ref-appearance')).toBeNull() expect(shell.snapshot.draft).toBe('see @src/components/') }) @@ -1259,7 +1261,7 @@ describe('strips and variants', () => { describe('command launcher chrome and control seats', () => { it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => { const { view, slotCalls } = bench() - expect(view.getByLabelText('命令')).toBeTruthy() + expect(view.getByLabelText('指令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() // Every seat dispatched, nothing rendered (render passes may repeat; the @@ -1275,7 +1277,7 @@ describe('command launcher chrome and control seats', () => { const toggleCommandMenu = vi.fn() const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu }) act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) }) - const launcher = view.getByLabelText('命令') + const launcher = view.getByLabelText('指令') expect(launcher.getAttribute('aria-expanded')).toBe('false') fireEvent.click(launcher) expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 }) @@ -1425,7 +1427,7 @@ describe('command launcher chrome and control seats', () => { it('disabled locks the Access chip and command launcher (running does not)', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 8d05546540..2133d03477 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -305,7 +305,7 @@ describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index 1230444cd5..7a1a63a8b6 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -338,7 +338,7 @@ describe('decorations: scanTextRefs', () => { it('recognizes directory paths independently of the dynamic lexicon', () => { const out = scanTextRefs('see @src/x/ now', new Map()) - expect(out).toEqual([{ start: 4, end: 11, trigger: '@', appearance: 'folder' }]) + expect(out).toEqual([{ start: 4, end: 11, trigger: '@' }]) }) it('names off the lexicon do not match; triggers are routed per lexicon list', () => { diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index d7772e006a..2603c648b3 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: 29d1c0930869232527cdbfbb091f3d2126db68e9 -README.zh.md: 45f5334ef3988d5c64e0707902609a2b21d46368 +README.md: 05c44fee27f0500c6a5d53496fde3d447edbf424 +README.zh.md: f280356eb37b364f49851ca24d73370eb62e6795 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 29d1c09308..05c44fee27 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s ### The command-input bubble -Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `命令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. +Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. ### Failures diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 45f5334ef3..f280356eb3 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -27,9 +27,9 @@ kind: "package-reference" 与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。active 的 goal 提供暂停动作;paused 的提供恢复;编辑重写目标文本;清除移除目标,并在投影追上之前抑制条带。 -### 命令输入气泡 +### 指令输入气泡 -每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `命令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 +每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 ### 失败 @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -条带是投影模式:活目标经 `useProjection('goal')` 到达(由历史尾页播种、`session/projection` 帧更新),因此插件不持有领域 store、不设刷新链、不挂事件监听。注入面只携带四个变更动词,经 `ctx.remote.goals` 调用;每个动词在调用时从会话当前投影值读取 CAS ref,比较并交换(RPC 的 CAS)就是陈旧性护栏。由于 React 的 pending 渲染无法拦住同一帧内的点击,条带会同步为变更建立 single-flight 防护。命令输入投影是独立的 Conversation Definition,在通用命令结果 Node 之前构建 `command-input` Chat Node;它绝不创建 `user/message` 或模型轮次。 +条带是投影模式:活目标经 `useProjection('goal')` 到达(由历史尾页播种、`session/projection` 帧更新),因此插件不持有领域 store、不设刷新链、不挂事件监听。注入面只携带四个变更动词,经 `ctx.remote.goals` 调用;每个动词在调用时从会话当前投影值读取 CAS ref,比较并交换(RPC 的 CAS)就是陈旧性护栏。由于 React 的 pending 渲染无法拦住同一帧内的点击,条带会同步为变更建立 single-flight 防护。指令输入投影是独立的 Conversation Definition,在通用命令结果 Node 之前构建 `command-input` Chat Node;它绝不创建 `user/message` 或模型轮次。
diff --git a/packages/client/ui-goal/src/client/locales.ts b/packages/client/ui-goal/src/client/locales.ts index 5fd6411573..055991e277 100644 --- a/packages/client/ui-goal/src/client/locales.ts +++ b/packages/client/ui-goal/src/client/locales.ts @@ -6,7 +6,7 @@ export const zh = { 'phase.paused': '已暂停的目标', 'phase.blocked': '受阻的目标', 'objective.aria': '目标内容', - 'commandInput.aria': '命令输入', + 'commandInput.aria': '指令输入', 'action.save': '保存目标', 'action.cancel': '取消编辑', 'action.pause': '暂停目标', diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index d8fc191f84..6c8c1e34cc 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -129,7 +129,7 @@ describe('goal command input projection', () => { t, } as unknown as Parameters[0] const view = render() - const bubble = view.getByRole('group', { name: '命令输入' }) + const bubble = view.getByRole('group', { name: '指令输入' }) expect(bubble.textContent).toBe('/goal ship it') expect(within(bubble).queryByRole('button')).toBeNull() diff --git a/packages/client/ui-input-trigger/src/client/MenuView.module.css b/packages/client/ui-input-trigger/src/client/MenuView.module.css index f93bb2b1a5..817831f393 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.module.css +++ b/packages/client/ui-input-trigger/src/client/MenuView.module.css @@ -1,13 +1,11 @@ .menu { position: absolute; bottom: calc(100% + 4px); + /* The overlay anchor is exactly the composer card's width; both insets pin + the menu edge to edge with border and padding kept inside. */ left: 0; + right: 0; z-index: 100; - min-width: min(260px, 100%); - /* 537 is the design cap; the 100% clamp keeps the menu inside the composer - card when a narrow viewport shrinks the card below the cap (the overlay - anchor is exactly the card's width). */ - max-width: min(537px, 100%); /* Height cap: the 320px design maximum, clamped at runtime to the space * above the composer (inline max-height set in MenuView.tsx). */ max-height: 320px; @@ -49,7 +47,8 @@ text-align: left; } -.item:hover, +/* One shared highlight: pointer motion parks it here through the hover event, + so there is no separate :hover tint competing with the keyboard's. */ .item.active { background: var(--dsw-alias-interactive-bg-hover); } @@ -95,19 +94,54 @@ color: var(--dsw-alias-label-tertiary); } -/* Trailing descent affordance on drillable rows (directories): a quiet - chevron that brightens on its own hover, separate from the row pick. */ +/* Trailing seat on drillable rows (directories): the Tab keycap hint plus the + drill chevron, revealed while the row holds the shared highlight. */ +.trailing { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +/* Hint naming the keyboard twin of the chevron — a caption line plus a Tab + keycap: Tab drills the highlighted row. Hidden at rest so idle rows stay + quiet. */ +.drillHintText { + display: none; + color: var(--dsw-alias-label-caption); + font-size: 11px; + line-height: 18px; + white-space: nowrap; +} + +.drillHint { + display: none; + padding: 0 5px; + border-radius: 4px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-caption); + font-family: inherit; + font-size: 11px; + line-height: 18px; +} + +.item.active .drillHintText, +.item.active .drillHint { + display: inline-flex; +} + +/* Descent affordance: a quiet chevron that brightens on its own hover, + separate from the row pick. */ .drill { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px; - margin-left: auto; border-radius: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 1; + /* label-caption, the quiet tint the composer's access-mode chevron uses. */ + color: var(--dsw-alias-label-caption); } .drill:hover { @@ -124,13 +158,28 @@ color: var(--dsw-alias-label-tertiary); } -/* Pending-source row: same cell metrics, dimmed label. */ -.loading { +/* Pending-source skeleton rows: item cell metrics around a breathing bar + (deepsuite Skeleton text variant: 2s color pulse; here the single + bg-skeleton token pulses through opacity instead of a second color). */ +.skeletonRow { display: flex; align-items: center; + /* border-box so min-height matches the .item buttons, whose UA default + box-sizing already counts the same 8px paddings inside their 40px. */ + box-sizing: border-box; min-height: 40px; padding: 8px 10px; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-dimmed); +} + +.skeletonBar { + height: 20px; + border-radius: 4px; + background: var(--dsw-alias-bg-skeleton); + animation: dsh-menu-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; +} + +@keyframes dsh-menu-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } } diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index 543f76eda8..3369874e77 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -2,14 +2,14 @@ * Trigger candidate menu: renders the InputTriggerService menu store into the * conversation.input.overlay anchor. Closed state renders null (the overlay * slot stays mounted); groups render in roster order under localized title - * rows, pending groups as a loading row; pointer picks route back through + * rows, pending groups as two skeleton rows; pointer picks route back through * the service (combobox pattern — focus never leaves the textarea, so rows * are mousedown-handled and the highlight is exposed via * aria-activedescendant on the listbox). */ import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react' import clsx from 'clsx' -import { useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronRightOutline14, ReferenceIcon, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import css from './MenuView.module.css' import type { MenuViewInjected } from './slots.ts' @@ -31,7 +31,7 @@ function optionId(source: string, index: number): string { * @param props - injected face (the menu store and the pick route); `t` rides the standard locale seat. * @returns the dropdown while open; null while closed. */ -export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { +export function MenuView({ menu, onPick, onHover, onDismiss, t }: MenuViewProps) { const state = useSyncExternalStore( fn => menu.subscribe(fn), () => menu.getSnapshot(), @@ -85,7 +85,12 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { ? null :
{t(group.source as MenuKey)}
} {group.status === 'pending' - ?
{t('loading')}
+ ? ( +
+
+
+
+ ) : group.items.map((item, index) => { const active = highlight !== null && highlight.source === group.source && highlight.index === index return ( @@ -106,24 +111,38 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { ev.preventDefault() onPick(group.source, index) }} + // mousemove, not mouseenter: real pointer motion moves the + // shared highlight; keyboard scrolling rows under a resting + // pointer must not steal it back. + onMouseMove={active ? undefined : () => { onHover(group.source, index) }} > - {item.icon !== undefined && {item.icon}} + {item.icon !== undefined && ( + + + + )} {item.name} {item.description !== undefined && {item.description}} {item.drill === true && ( - { - ev.preventDefault() - ev.stopPropagation() - onPick(group.source, index, 'drill') - }} - > - › + + {/* Visual hint only: Tab drills the highlighted row (the + keyboard twin of the chevron, which owns the aria label). */} + {t('drill.hint')} + {t('drill.key')} + { + ev.preventDefault() + ev.stopPropagation() + onPick(group.source, index, 'drill') + }} + > + + )} diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 71827214bd..b69d423213 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -178,6 +178,18 @@ export class InputTriggerController { this.execute(outcome, hit.span) } + /** + * Pointer hover from MenuView: park the shared highlight on the hovered + * candidate (keyboard `move` and pointer hover drive one highlight — + * last input wins). + * @param source - source (group) name. + * @param index - candidate index within the group. + */ + hover(source: string, index: number): void { + if (this.disposed) return + this.reduce({ type: 'hover', source, index }) + } + /** * Keyboard arbitration while the menu is open. * @param key - intercepted key. diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index c79f3d010d..4c586bc7e0 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -75,6 +75,7 @@ export function apply(ctx: ClientContext): void { return { menu: controller.menu, onPick: (source, index, action) => { controller.pick(source, index, action) }, + onHover: (source, index) => { controller.hover(source, index) }, onDismiss: () => { controller.dismiss() }, } }, diff --git a/packages/client/ui-input-trigger/src/client/locales.ts b/packages/client/ui-input-trigger/src/client/locales.ts index f8d5dab806..4f689a3a3d 100644 --- a/packages/client/ui-input-trigger/src/client/locales.ts +++ b/packages/client/ui-input-trigger/src/client/locales.ts @@ -6,11 +6,13 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'command': '命令', + 'command': '指令', 'skill': '技能', 'subagent': '子智能体', 'loading': '正在加载…', 'drill.aria': '进入目录', + 'drill.hint': '进入目录', + 'drill.key': 'Tab', 'suggestions.aria': '触发候选建议', } satisfies Record @@ -24,5 +26,7 @@ export const en = { 'subagent': 'Subagents', 'loading': 'Loading…', 'drill.aria': 'Browse folder', + 'drill.hint': 'Browse folder', + 'drill.key': 'Tab', 'suggestions.aria': 'Trigger suggestions', } satisfies Record diff --git a/packages/client/ui-input-trigger/src/client/slots.ts b/packages/client/ui-input-trigger/src/client/slots.ts index 85265f8d72..93dad04f03 100644 --- a/packages/client/ui-input-trigger/src/client/slots.ts +++ b/packages/client/ui-input-trigger/src/client/slots.ts @@ -15,6 +15,13 @@ export interface MenuViewInjected { * @param action - settling pick (default) or the candidate's drill action. */ onPick: (source: string, index: number, action?: PickAction) => void + /** + * Pointer hover routed to the shared highlight (pointer and keyboard drive + * one highlight — last input wins). + * @param source - source (group) name. + * @param index - candidate index within the group. + */ + onHover: (source: string, index: number) => void /** Dismiss the menu (external pointer outside the composer area). */ onDismiss: () => void } diff --git a/packages/client/ui-input-trigger/src/core/contract.ts b/packages/client/ui-input-trigger/src/core/contract.ts index 685364f489..eab79f1066 100644 --- a/packages/client/ui-input-trigger/src/core/contract.ts +++ b/packages/client/ui-input-trigger/src/core/contract.ts @@ -51,6 +51,7 @@ export type MenuEvent = | { readonly type: 'source-settled'; readonly generation: number; readonly source: string; readonly items?: readonly InputTriggerCandidate[] } | { readonly type: 'source-failed'; readonly generation: number; readonly source: string } | { readonly type: 'move'; readonly dir: 1 | -1 } + | { readonly type: 'hover'; readonly source: string; readonly index: number } | { readonly type: 'close' } /** Pure menu reducer; returns the same reference when the event is stale or a no-op. */ diff --git a/packages/client/ui-input-trigger/src/core/menu.ts b/packages/client/ui-input-trigger/src/core/menu.ts index 3fe40ee18c..47a6090d8f 100644 --- a/packages/client/ui-input-trigger/src/core/menu.ts +++ b/packages/client/ui-input-trigger/src/core/menu.ts @@ -81,7 +81,8 @@ const allReadyEmpty = (groups: MenuState['groups']): boolean => * open menu, or the roster is dropped; a settlement or failure leaving every * group ready-and-empty (or no groups) auto-closes; `source-failed` silently * removes the group (the shell logs); `move` cycles the highlight across - * ready items. + * ready items; `hover` parks it on one ready item (pointer and keyboard + * share the single highlight — last input wins). * * @param state - Current menu state. * @param ev - Menu event. @@ -131,6 +132,14 @@ export const menuReduce: MenuReduce = (state, ev) => { if (hl && next.source === hl.source && next.index === hl.index) return state return { ...state, highlight: next } } + case 'hover': { + if (!state.open) return state + const target = validHighlight({ source: ev.source, index: ev.index }, state.groups) + if (target === null) return state + const hl = state.highlight + if (hl && hl.source === target.source && hl.index === target.index) return state + return { ...state, highlight: target } + } case 'close': return closed(state) } diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 1f8f3658e2..1abde9de66 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -40,11 +40,14 @@ export type PickVia = 'menu' | 'space' | 'enter' /** What a pick asks for: resolve the candidate, or drill into it in place. */ export type PickAction = 'pick' | 'drill' +/** Leading glyph token of one menu candidate, mapped to its SVG by the menu view. */ +export type InputTriggerCandidateIcon = 'file' | 'folder' | 'session' + /** One menu candidate. Pure display data — zero behavior declaration. */ export interface InputTriggerCandidate { readonly name: string readonly description?: string - readonly icon?: string + readonly icon?: InputTriggerCandidateIcon readonly hint?: string /** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */ readonly section?: string diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index 9cf41c62a1..182252ee38 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -50,7 +50,7 @@ describe('apply', () => { const { ctx, locale } = await bench() await ctx.plugin({ inject: [...inject], apply }).await() const t = locale.bind('slash.menu') - expect(t('command')).toBe('命令') + expect(t('command')).toBe('指令') locale.setLocale('en') expect(t('skill')).toBe('Skills') expect(t('subagent')).toBe('Subagents') diff --git a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts index a849a7c69d..532f608fcf 100644 --- a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts @@ -16,6 +16,14 @@ function open(sources: readonly string[], h: TriggerHit = hit()): MenuState { const item = (name: string) => ({ name }) +/** Two ready groups: command [goal, model], skill [commit]. */ +function ready(): MenuState { + let s = open(['command', 'skill']) + s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] }) + s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] }) + return s +} + describe('menuReduce hit', () => { it('opens a new generation with all groups pending', () => { const s = open(['command', 'skill']) @@ -147,14 +155,6 @@ describe('menuReduce source-failed', () => { }) describe('menuReduce move', () => { - /** Two ready groups: command [goal, model], skill [commit]. */ - function ready(): MenuState { - let s = open(['command', 'skill']) - s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] }) - s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] }) - return s - } - it('cycles forward across groups and wraps', () => { let s = ready() s = menuReduce(s, { type: 'move', dir: 1 }) @@ -195,6 +195,28 @@ describe('menuReduce move', () => { }) }) +describe('menuReduce hover', () => { + it('parks the highlight on the hovered ready item', () => { + const s = menuReduce(ready(), { type: 'hover', source: 'skill', index: 0 }) + expect(s.highlight).toEqual({ source: 'skill', index: 0 }) + }) + + it('is a no-op reference when closed, on invalid targets, and on the current highlight', () => { + const closed = menuReduce(ready(), { type: 'close' }) + expect(menuReduce(closed, { type: 'hover', source: 'command', index: 0 })).toBe(closed) + const s = ready() + expect(menuReduce(s, { type: 'hover', source: 'ghost', index: 0 })).toBe(s) + expect(menuReduce(s, { type: 'hover', source: 'command', index: 5 })).toBe(s) + expect(menuReduce(s, { type: 'hover', source: 'command', index: 0 })).toBe(s) + }) + + it('ignores a hover into a still-pending group', () => { + let s = open(['command', 'skill']) + s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] }) + expect(menuReduce(s, { type: 'hover', source: 'skill', index: 0 })).toBe(s) + }) +}) + describe('menuReduce close', () => { it('clears everything but keeps the generation for stale-drop', () => { let s = open(['command']) diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index c9bf518b08..48b5d52136 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -2,10 +2,10 @@ /** * MenuView rendering spec, props-direct: closed store * renders null, groups render in roster order under localized title rows - * (unknown sources fall back to the raw name) with pending rows as loading, - * pointer picks route (source, index) back without stealing focus, the - * highlight is exposed through aria-activedescendant + aria-selected, and - * the list height clamps to the space above the composer. + * (unknown sources fall back to the raw name) with pending rows as skeleton + * placeholders, pointer picks route (source, index) back without stealing + * focus, the highlight is exposed through aria-activedescendant + + * aria-selected, and the list height clamps to the space above the composer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -32,7 +32,7 @@ function openState(partial?: Partial): MenuState { hit, generation: 1, groups: [ - { source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: '⚑' }, { name: 'plan' }] }, + { source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: 'file' }, { name: 'plan' }] }, { source: 'skill', status: 'pending', items: [] }, ], highlight: { source: 'command', index: 0 }, @@ -60,9 +60,10 @@ const t = makeTranslate(zh, commonZh) function mount(state: MenuState) { const menu = createSnapshotStore(state) const onPick = vi.fn() + const onHover = vi.fn() const onDismiss = vi.fn() - const view = render() - return { menu, onPick, onDismiss, view } + const view = render() + return { menu, onPick, onHover, onDismiss, view } } /** The non-interactive group title rows (role=presentation), in document order. */ @@ -81,11 +82,15 @@ describe('MenuView', () => { expect(view.container.childElementCount).toBe(0) }) - it('renders ready groups as option rows and pending groups as loading rows', () => { + it('renders ready groups as option rows and pending groups as two skeleton rows', () => { mount(openState()) const options = screen.getAllByRole('option') - expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan']) - expect(screen.queryByText('正在加载…')).not.toBeNull() + expect(options.map(o => o.textContent)).toEqual(['goalSet up a goal', 'plan']) + // The icon token renders as an SVG glyph, not text. + expect(options[0]?.querySelector('svg')).not.toBeNull() + expect(options[1]?.querySelector('svg')).toBeNull() + const status = screen.getByRole('status', { name: '正在加载…' }) + expect(status.children).toHaveLength(2) }) it('keeps an opted-out source title hidden while its candidates are pending', () => { @@ -94,7 +99,7 @@ describe('MenuView', () => { highlight: null, })) expect(screen.queryByText('reference')).toBeNull() - expect(screen.getByText('正在加载…')).toBeTruthy() + expect(screen.getByRole('status', { name: '正在加载…' })).toBeTruthy() }) it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => { @@ -106,7 +111,7 @@ describe('MenuView', () => { { source: 'skill', status: 'pending', items: [] }, ], })) - expect(titles(view.container)).toEqual(['命令', 'mystery', '技能']) + expect(titles(view.container)).toEqual(['指令', 'mystery', '技能']) }) it('renders contiguous candidate sections once without changing option indexes', () => { @@ -117,14 +122,14 @@ describe('MenuView', () => { items: [ { name: 'Folder · src/', section: '文件与文件夹' }, { name: 'File · README.md', section: '文件与文件夹' }, - { name: 'Session · Research', section: 'Session 对话' }, + { name: 'Session · Research', section: '对话' }, ], }], highlight: { source: 'reference', index: 0 }, })) expect(screen.queryByText('reference')).toBeNull() expect(screen.getAllByText('文件与文件夹')).toHaveLength(1) - expect(screen.getAllByText('Session 对话')).toHaveLength(1) + expect(screen.getAllByText('对话')).toHaveLength(1) const options = screen.getAllByRole('option') expect(options.map(option => option.textContent)).toEqual([ 'Folder · src/', @@ -219,7 +224,7 @@ describe('MenuView', () => { const onDismiss = vi.fn() render(
- +
, ) @@ -252,4 +257,15 @@ describe('MenuView', () => { expect(notPrevented).toBe(false) expect(onPick).toHaveBeenCalledWith('command', 1) }) + + it('pointer motion over a row routes hover; the highlighted row stays silent', () => { + const { onHover } = mount(openState()) + const options = screen.getAllByRole('option') + fireEvent.mouseMove(options[1]!) + expect(onHover).toHaveBeenCalledWith('command', 1) + onHover.mockClear() + // Index 0 already holds the highlight: no hover round-trip. + fireEvent.mouseMove(options[0]!) + expect(onHover).not.toHaveBeenCalled() + }) }) diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index b494abf478..b6849cfb4c 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -700,6 +700,18 @@ describe('arbitrate', () => { expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) }) + it('hover parks the shared highlight; disposed controllers ignore it', async () => { + const { controller } = await menuBench() + controller.hover('command', 1) + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 }) + // Keyboard keeps moving from the parked spot: last input wins. + expect(controller.arbitrate('up', false)).toBe('consumed') + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) + controller.dispose() + controller.hover('command', 1) + expect(controller.menu.getSnapshot().highlight).toBeNull() + }) + it('enter picks the highlight through the pipeline', async () => { const { controller, cmd } = await menuBench() expect(controller.arbitrate('enter', false)).toBe('pick-highlighted') diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 7b87af0e1f..392fe365f8 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -111,8 +111,9 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean mention, } return [{ - name: `${t(directory ? 'candidate.folder' : 'candidate.file')} · ${name}${directory ? '/' : ''}`, + name: `${name}${directory ? '/' : ''}`, description: candidate.path, + icon: directory ? 'folder' as const : 'file' as const, section: t('section.files'), value: JSON.stringify(value), ...(directory ? { drill: true } : {}), @@ -128,8 +129,9 @@ function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Transl mention: candidate.mention, } return { - name: `${t('candidate.session')} · ${candidate.label}`, + name: candidate.label, description, + icon: 'session' as const, section: t('section.sessions'), value: JSON.stringify(value), } diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts index 41ec525e75..e0885f9f3c 100644 --- a/packages/client/ui-reference/src/client/locales.ts +++ b/packages/client/ui-reference/src/client/locales.ts @@ -8,10 +8,7 @@ export const NS = 'reference' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'section.files': '文件与文件夹', - 'section.sessions': 'Session 对话', - 'candidate.file': '文件', - 'candidate.folder': '文件夹', - 'candidate.session': 'Session', + 'section.sessions': '对话', 'candidate.noCwd': '(无工作目录)', } satisfies Record @@ -28,9 +25,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** English dictionary, checked complete against the zh key set. */ export const en = { 'section.files': 'Files & folders', - 'section.sessions': 'Session conversations', - 'candidate.file': 'File', - 'candidate.folder': 'Folder', - 'candidate.session': 'Session', + 'section.sessions': 'Sessions', 'candidate.noCwd': '(no cwd)', } satisfies Record diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 5b782521e0..bfb6b919dc 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -167,19 +167,22 @@ describe('candidates', () => { releaseFiles() await expect(pending).resolves.toEqual([ expect.objectContaining({ - name: 'Folder · src/', + name: 'src/', description: 'src', + icon: 'folder', section: 'Files & folders', }), expect.objectContaining({ - name: 'File · a b.md', + name: 'a b.md', description: 'docs/a b.md', + icon: 'file', section: 'Files & folders', }), expect.objectContaining({ - name: 'Session · Research', + name: 'Research', description: 'source · /project · 2023-11-14T22:13:20.000Z', - section: 'Session conversations', + icon: 'session', + section: 'Sessions', }), ]) }) @@ -203,7 +206,7 @@ describe('candidates', () => { })) const { source } = await bench(files, sessions) const quoted = await source.candidates(session, request('READ', { quoted: true })) - expect(quoted).toEqual([expect.objectContaining({ name: 'File · README.md' })]) + expect(quoted).toEqual([expect.objectContaining({ name: 'README.md', icon: 'file' })]) expect(source.onPick({ candidate: quoted[0]!, session, @@ -222,7 +225,7 @@ describe('candidates', () => { }) expect(sessions).not.toHaveBeenCalled() await expect(source.candidates(session, request('research'))).resolves.toEqual([ - expect.objectContaining({ name: 'Session · Research' }), + expect.objectContaining({ name: 'Research', icon: 'session' }), ]) }) @@ -269,7 +272,7 @@ describe('candidates', () => { const { source } = await bench(files, sessions) await expect(source.candidates(session, request('same'))).resolves.toEqual([ expect.objectContaining({ - name: 'Session · same', + name: 'same', description: '(no cwd) · 1970-01-01T00:00:00.000Z', }), ]) @@ -320,7 +323,7 @@ describe('pick and codec', () => { it('inserts sessions as atomic chips whose clipboard and model forms are canonical mentions', async () => { const { source } = await bench() const candidates = await source.candidates(session, request('')) - const candidate = candidates.find(item => item.name === 'Session · Research')! + const candidate = candidates.find(item => item.name === 'Research')! const mention = '@[Research](dsh-session:InNvdXJjZSI)' expect(pick(source, candidate)).toEqual({ insert: { diff --git a/snapshots/web/approval-composer/session.jsonl b/snapshots/web/approval-composer/session.jsonl index 5b3c8cfdd1..05b8474f65 100644 --- a/snapshots/web/approval-composer/session.jsonl +++ b/snapshots/web/approval-composer/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787528546562,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736494132,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index f4b07c037f..e633f1ca1c 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -26,7 +26,7 @@ - 'button "Failed Bash Error: tool call aborted before dispatch"': - img - text: "Failed Bash Error: tool call aborted before dispatch" -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Full access"': Full access diff --git a/snapshots/web/code-mode-round/session.jsonl b/snapshots/web/code-mode-round/session.jsonl index bb4ae42caa..f8b3b161a3 100644 --- a/snapshots/web/code-mode-round/session.jsonl +++ b/snapshots/web/code-mode-round/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787628995177,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736414061,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[17,17,17,15,16,16,17,17,17,17,16,17,17,17,16,16,17,17,15,16,15,17,17,17,16,17,16,16,17,17,15,16,17,16,17,16,15,17,17,15,16,17,17,15,17,15,16,16,16,16,15,16,16,17,17,17,16,16,17,17,17,16,15,17,16,16,16,16,16,17,16,16,16,16],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Runs"," bash"," to"," echo"," \"","CODE","_RO","UND","_OK","\"\n","2","."," T","ries"," to"," read"," a"," file"," \"","missing",".txt","\""," and"," catches"," the"," error","\n","3","."," Returns"," an"," object"," with"," both"," outcomes","\n","4","."," They"," also"," want"," me"," to"," reply"," \"","D","ONE","\""," and"," stop"," after","\n\n","Let"," me"," write"," this"," program","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[15,16,17,16,16,16,16,16,17,17,17,16,16,17,16,16,17,17,17,16,17,16,16,17,16,17,17,16,16,17,16,17,17,17,16,18,19,17,17,19,23,16,17,16,17,17,16,17,15,17,17,17,17,16,16,17,17,16,16,17,15,17,17,16,16,16,17,16,17,16,17,17,16,17],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Runs"," bash"," to"," echo"," \"","CODE","_RO","UND","_OK","\"\n","2","."," T","ries"," to"," read"," a"," file"," \"","missing",".txt","\""," and"," catches"," the"," error","\n","3","."," Returns"," an"," object"," with"," both"," outcomes","\n","4","."," They"," also"," want"," me"," to"," reply"," \"","D","ONE","\""," and"," stop"," after","\n\n","Let"," me"," write"," this"," program","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[15,16,15,16,16,16,17,17,17,16,16,16,16,16,16,16,16,16,15,16,16,17,15,16,16,16,15,16,16,16,16,16,16,16,16,15,16,17,16,16,16,16,15,15,16,16,16,16,17,16,17,17,15,16,17,17,16,15,16,17,17,15,16,15,16,16,16,15,16,16,16,17,16,16,16,17,17,16,16,16,16,16,17,17,17,17,17,16,16,17,17,16,16,16,16,16,17,15,15,16,15,17,17,15,16,16,16,17,16,14,17,17,17,17,15,16,16,15],"id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","args":["","{","\"","description","\"",": ","\"","Run"," bash"," echo"," and"," catch"," missing"," file"," read","\"",", ","\"","code","\"",": ","\"","\\n","const"," bash","Result"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_RO","UND","_OK","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_RO","UND","_OK","\\\"\\n","});\\n\\n","let"," read","Error"," ="," null",";\\n","try"," {\\n"," "," await"," tools",".read","({"," file","_path",":"," \\\"","missing",".txt","\\\""," });\\n","}"," catch"," (","e",")"," {\\n"," "," read","Error"," ="," {\\n"," "," tool","Name",":"," e",".t","ool","Name",",\\n"," "," message",":"," e",".message","\\n"," "," };\\n","}\\n\\n","return"," {"," bash",":"," bash","Result",".stdout",".text",".trim","(),"," read","Error"," };\\n","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[17,17,16,16,17,16,17,17,16,17,17,15,17,16,17,16,17,17,17,17,16,17,16,17,16,16,16,17,16,17,16,17,17,17,16,17,16,16,17,16,16,16,17,16,17,15,16,17,16,16,17,16,16,17,15,18,16,17,16,16,17,16,17,17,16,17,16,16,17,16,17,16,17,16,17,16,17,17,16,16,17,16,17,16,17,17,16,16,17,15,17,16,15,17,16,16,16,17,16,17,17,16,17,16,17,19,17,17,16,18,15,17,16,15,17,16,16,17],"id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","args":["","{","\"","description","\"",": ","\"","Run"," bash"," echo"," and"," catch"," missing"," file"," read","\"",", ","\"","code","\"",": ","\"","\\n","const"," bash","Result"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_RO","UND","_OK","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_RO","UND","_OK","\\\"\\n","});\\n\\n","let"," read","Error"," ="," null",";\\n","try"," {\\n"," "," await"," tools",".read","({"," file","_path",":"," \\\"","missing",".txt","\\\""," });\\n","}"," catch"," (","e",")"," {\\n"," "," read","Error"," ="," {\\n"," "," tool","Name",":"," e",".t","ool","Name",",\\n"," "," message",":"," e",".message","\\n"," "," };\\n","}\\n\\n","return"," {"," bash",":"," bash","Result",".stdout",".text",".trim","(),"," read","Error"," };\\n","\"","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}}} @@ -29,7 +29,7 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[16,16,16,16,16,17,15,16,15,16,16,15,16],"texts":["The"," program"," ran"," successfully","."," Let"," me"," now"," reply"," D","ONE"," as"," instructed","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[16,16,17,16,17,16,16,16,16,17,17,15,16],"texts":["The"," program"," ran"," successfully","."," Let"," me"," now"," reply"," D","ONE"," as"," instructed","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} diff --git a/snapshots/web/code-mode-round/ui.expected.md b/snapshots/web/code-mode-round/ui.expected.md index bcdd6b6c5d..8df10012df 100644 --- a/snapshots/web/code-mode-round/ui.expected.md +++ b/snapshots/web/code-mode-round/ui.expected.md @@ -47,7 +47,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/cordis-tool-round/session.jsonl b/snapshots/web/cordis-tool-round/session.jsonl index 3ca3849135..354033a43e 100644 --- a/snapshots/web/cordis-tool-round/session.jsonl +++ b/snapshots/web/cordis-tool-round/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787530430299,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736473857,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index 286cd191b5..0fa28bef4b 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -100,7 +100,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index df302f69ed..f654d948bf 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -38,7 +38,7 @@ - img - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled." -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/feedback-command/session.jsonl b/snapshots/web/feedback-command/session.jsonl index 322863bd92..66f07aa443 100644 --- a/snapshots/web/feedback-command/session.jsonl +++ b/snapshots/web/feedback-command/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520609412,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736480169,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,7 +12,7 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0],"texts":["L","IGH","TH","O","USE"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}} @@ -24,4 +24,4 @@ {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"command/run","data":{"commandId":"{{command:1}}","name":"feedback","source":{"kind":"user"}}} {"type":"feedback/record","data":{"text":"the diff view is unreadable"}} -{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Feedback recorded for session {{session:1}}\nAnonymous user: {{rpc:1}}. Session sharing is enabled."}} +{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Feedback recorded for session {{session:1}}\nAnonymous user: {{anonymousUserId}}. Session sharing is enabled."}} diff --git a/snapshots/web/fresh-round-trip/session.jsonl b/snapshots/web/fresh-round-trip/session.jsonl index eeb83c93c3..932a747a85 100644 --- a/snapshots/web/fresh-round-trip/session.jsonl +++ b/snapshots/web/fresh-round-trip/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520129674,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736463867,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,15,17,16,18,18,17,16,18,17,16,18,16,14,17,16],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,15,16,15,17,16,16,15,17,16,16,15,16,17,17,16],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," reply"," with"," \"","D","ONE","\"."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[17,18,17,15,17,17,16,17,16,17,17,16,18,16,18,15,17,16,17,15,17,14,17,18,18,18],"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," WEB","_E","2","E","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," the"," test"," string","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[16,17,16,16,16,17,16,16,16,16,16,17,17,18,15,17,16,16,17,16,15,16,16,16,16,17],"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," WEB","_E","2","E","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," the"," test"," string","\"","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}} @@ -25,7 +25,7 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,17,18,15,17,17,17,17,15,17,17,16,17,18,17,15,17,17,16,17,17,17],"texts":["The"," command"," executed"," successfully"," and"," output"," \"","WEB","_E","2","E","_OK","\"."," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[16,16,16,17,17,17,17,16,16,17,16,17,17,16,17,16,16,16,17,16,17,16],"texts":["The"," command"," executed"," successfully"," and"," output"," \"","WEB","_E","2","E","_OK","\"."," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index c7822c503d..24717b214d 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -42,7 +42,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/goal-multi-turn-actions/session.jsonl b/snapshots/web/goal-multi-turn-actions/session.jsonl index 78d92f9a19..436768541a 100644 --- a/snapshots/web/goal-multi-turn-actions/session.jsonl +++ b/snapshots/web/goal-multi-turn-actions/session.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787640083383,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736496504,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} {"type":"command/run","data":{"commandId":"{{command:1}}","name":"goal","args":" 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","source":{"kind":"user"}}} -{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"{{id:1}}","revision":1,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"active","maxGoalRounds":256},"roundsStarted":0,"createdAt":1787640083556,"updatedAt":1787640083556}} +{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"{{id:1}}","revision":1,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"active","maxGoalRounds":256},"roundsStarted":0,"createdAt":1787736496698,"updatedAt":1787736496698}} {"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Goal created\nStatus: active\nObjective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\nRounds: 0/256\nActivation: armed\n\nCommands: /goal edit , /goal pause, /goal clear"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nRound: 1/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"{{id:1}}","revision":1,"round":1},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} @@ -84,9 +84,9 @@ {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["Turn"," ","1"," is"," done","."," Per"," the"," objective",":"," \"","你","做完","一个","turn","之后",",","直接","输出","内容",",","停止","\""," —"," after"," finishing"," a"," turn",","," directly"," output"," the"," content"," and"," stop","."," The"," system"," will"," open"," another"," turn",".\n\n","So"," I"," should"," just"," output"," the"," file"," structure"," of"," this"," randomly"," picked"," package"," (","pack","ages","/","context","/s","ession","-reference",")"," and"," stop","."," I"," should"," NOT"," mark"," the"," goal"," complete"," since"," there"," are"," ","2"," turns"," and"," this"," is"," only"," turn"," ","1","."," The"," objective"," says"," the"," system"," will"," open"," another"," turn"," —"," so"," I"," should"," leave"," the"," goal"," active"," for"," the"," next"," round",".\n\n","Let"," me"," output"," the"," file"," structure"," in"," a"," nice"," tree"," format","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":6,"index":0,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["Turn"," ","1"," is"," done","."," Per"," the"," objective",":"," \"","你","做完","一个","turn","之后",",","直接","输出","内容",",","停止","\""," —"," after"," finishing"," a"," turn",","," directly"," output"," the"," content"," and"," stop","."," The"," system"," will"," open"," another"," turn",".\n\n","So"," I"," should"," just"," output"," the"," file"," structure"," of"," this"," randomly"," picked"," package"," (","pack","ages","/","context","/s","ession","-reference",")"," and"," stop","."," I"," should"," NOT"," mark"," the"," goal"," complete"," since"," there"," are"," ","2"," turns"," and"," this"," is"," only"," turn"," ","1","."," The"," objective"," says"," the"," system"," will"," open"," another"," turn"," —"," so"," I"," should"," leave"," the"," goal"," active"," for"," the"," next"," round",".\n\n","Let"," me"," output"," the"," file"," structure"," in"," a"," nice"," tree"," format","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":6,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["**","Turn"," ","1"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","context","/s","ession","-reference","`"," 📦","\n\n","```\n","session","-reference","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," ├","──"," index",".ts","\n","│"," "," └","──"," uri",".ts","\n","└","──"," tests","\n"," "," └","──"," session","-reference",".s","pec",".ts","\n","```\n\n","```\n","session","-reference","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," ├","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","\n","│"," "," └","──"," uri",".ts"," "," #"," ","会话","引用"," URI"," ","相关","实现","\n","└","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","这是一个","很","典型的","轻","量"," Type","Script"," ","包","结构",":","入口"," +"," ","实现"," +"," ","测试","。","这一","轮","到此","结束",",","等","系统","开启","下一个"," turn","。"]}} +{"type":"text-chunks","data":{"turn":1,"step":6,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],"texts":["**","Turn"," ","1"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","context","/s","ession","-reference","`"," 📦","\n\n","```\n","session","-reference","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," ├","──"," index",".ts","\n","│"," "," └","──"," uri",".ts","\n","└","──"," tests","\n"," "," └","──"," session","-reference",".s","pec",".ts","\n","```\n\n","```\n","session","-reference","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," ├","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","\n","│"," "," └","──"," uri",".ts"," "," #"," ","会话","引用"," URI"," ","相关","实现","\n","└","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","这是一个","很","典型的","轻","量"," Type","Script"," ","包","结构",":","入口"," +"," ","实现"," +"," ","测试","。","这一","轮","到此","结束",",","等","系统","开启","下一个"," turn","。"]}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn.\n\nSo I should just output the file structure of this randomly picked package (packages/context/session-reference) and stop. I should NOT mark the goal complete since there are 2 turns and this is only turn 1. The objective says the system will open another turn — so I should leave the goal active for the next round.\n\nLet me output the file structure in a nice tree format."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Turn 1 / 2** — 随机选中的包:`packages/context/session-reference` 📦\n\n```\nsession-reference\n├── README.md\n├── package.json\n├── src\n│ ├── index.ts\n│ └── uri.ts\n└── tests\n └── session-reference.spec.ts\n```\n\n```\nsession-reference/\n├── README.md # 包说明文档\n├── package.json # 包元数据与依赖声明\n├── src/\n│ ├── index.ts # 入口,导出公共 API\n│ └── uri.ts # 会话引用 URI 相关实现\n└── tests/\n └── session-reference.spec.ts # 单元测试\n```\n\n这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":318,"cacheReadTokens":9088,"reasoningTokens":123}}}} @@ -137,9 +137,9 @@ {"type":"step/end","data":{"turn":2,"step":3}} {"type":"step/start","data":{"turn":2,"step":4}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":2,"step":4,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["This"," is"," turn"," ","2"," of"," ","2","."," I","'ve"," output"," the"," file"," structure"," for"," `","pack","ages","/","ll","m","/t","oken","-meter","`."," Both"," turns"," are"," done","."," I"," should"," output"," the"," content",","," stop",","," and"," then"," mark"," the"," goal"," as"," complete"," since"," both"," turns"," are"," finished","."]}} +{"type":"reasoning-chunks","data":{"turn":2,"step":4,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["This"," is"," turn"," ","2"," of"," ","2","."," I","'ve"," output"," the"," file"," structure"," for"," `","pack","ages","/","ll","m","/t","oken","-meter","`."," Both"," turns"," are"," done","."," I"," should"," output"," the"," content",","," stop",","," and"," then"," mark"," the"," goal"," as"," complete"," since"," both"," turns"," are"," finished","."]}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":2,"step":4,"index":1,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["**","Turn"," ","2"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","ll","m","/t","oken","-meter","`"," 📦","\n\n","```\n","token","-meter","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," └","──"," index",".ts","\n","└","──"," tests","\n"," "," └","──"," token","-meter",".s","pec",".ts","\n","```\n\n","```\n","token","-meter","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," └","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","(","token"," ","计量","逻辑",")\n","└","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","两个"," turn"," ","都","完成了","(","turn"," ","1",":","`","session","-reference","`",",","turn"," ","2",":","`","token","-meter","`",")。","这一","轮","到此","结束","。"]}} +{"type":"text-chunks","data":{"turn":2,"step":4,"index":1,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["**","Turn"," ","2"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","ll","m","/t","oken","-meter","`"," 📦","\n\n","```\n","token","-meter","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," └","──"," index",".ts","\n","└","──"," tests","\n"," "," └","──"," token","-meter",".s","pec",".ts","\n","```\n\n","```\n","token","-meter","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," └","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","(","token"," ","计量","逻辑",")\n","└","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","两个"," turn"," ","都","完成了","(","turn"," ","1",":","`","session","-reference","`",",","turn"," ","2",":","`","token","-meter","`",")。","这一","轮","到此","结束","。"]}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","argumentsDelta":""}}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","argumentsDelta":"{}"}}} @@ -163,7 +163,7 @@ {"type":"assistant/chunk","data":{"turn":2,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":2,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"两个 turn 均已完成,目标达成,标记 goal 为完成。"},{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"{{id:1}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:24}}"},"usage":{"inputTokens":132,"outputTokens":157,"cacheReadTokens":10368,"reasoningTokens":44}},"sourceEventSeqs":[681,682,683,684,685,686,687,688],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":5,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"{{id:1}}\",\"revision\":1,\"action\":\"complete\"}"}} -{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"{{id:1}}","revision":2,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"complete","maxGoalRounds":256},"roundsStarted":2,"createdAt":1787640083556,"updatedAt":1787640084238}} +{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"{{id:1}}","revision":2,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"complete","maxGoalRounds":256},"roundsStarted":2,"createdAt":1787736496698,"updatedAt":1787736497205}} {"type":"tool/result","data":{"turn":2,"step":5,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":2,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"complete\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{message:25}}"}},"sourceEventSeqs":[690],"surfaceOp":"append"} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"},"role":"user","id":"{{message:26}}"}]}} {"type":"step/end","data":{"turn":2,"step":5}} @@ -171,7 +171,7 @@ {"type":"step/start","data":{"turn":2,"step":6}} {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"},"role":"user","id":"{{message:26}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":2,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["两个"," turn"," ","都","完成了"," ✅","\n\n","**","总结",":","**\n\n","-"," **","Turn"," ","1","**"," —"," ","随机","选中"," `","pack","ages","/","context","/s","ession","-reference","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," session","-reference","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," ├","──"," index",".ts","\n"," "," │"," "," └","──"," uri",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts","\n"," "," ```\n","-"," **","Turn"," ","2","**"," —"," ","随机","选中"," `","pack","ages","/","ll","m","/t","oken","-meter","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," token","-meter","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," └","──"," index",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts","\n"," "," ```\n\n","**","验证","方式",":","**"," ","两个","包的","随机","选择","都","通过"," `","python","3"," random",".choice","`"," ","从","工作","区的"," `","pack","ages","/","`"," ","目录","下","实际","枚举","的"," ","4"," ","个","候选","包","中","抽取","(","`","ui","-con","vers","ation","`","、","`","session","-reference","`","、","`","token","-meter","`","、","`","skill","-local","`","),","文件","结构","则","用"," `","find","`"," ","实际","列出","文件","后","整理","输出",",","均已","排除"," `","node","_modules","`"," ","和"," `.","git","`","。","第二次","随机","抽","到了","与"," turn"," ","1"," ","相同的","包",",","为避免","重复","换","抽","了","另一个","。\n\n","**","说明",":","**"," ","本轮","没有","产生","任何","文件","修改","或","提交","——","任务","本身就是","\"","输出","文件","结构","\",","所有","产物","都在","上面的","消息","文本","里","。\n\n","如果你","还想","看","剩下","两个","包","(","`","ui","-con","vers","ation","`","、","`","skill","-local","`",")","的结构",",","或者其他","格式","的输出","(","比如","带","文件","大小的","树","形","图","),","随时","告诉我","。"]}} +{"type":"text-chunks","data":{"turn":2,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["两个"," turn"," ","都","完成了"," ✅","\n\n","**","总结",":","**\n\n","-"," **","Turn"," ","1","**"," —"," ","随机","选中"," `","pack","ages","/","context","/s","ession","-reference","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," session","-reference","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," ├","──"," index",".ts","\n"," "," │"," "," └","──"," uri",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts","\n"," "," ```\n","-"," **","Turn"," ","2","**"," —"," ","随机","选中"," `","pack","ages","/","ll","m","/t","oken","-meter","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," token","-meter","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," └","──"," index",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts","\n"," "," ```\n\n","**","验证","方式",":","**"," ","两个","包的","随机","选择","都","通过"," `","python","3"," random",".choice","`"," ","从","工作","区的"," `","pack","ages","/","`"," ","目录","下","实际","枚举","的"," ","4"," ","个","候选","包","中","抽取","(","`","ui","-con","vers","ation","`","、","`","session","-reference","`","、","`","token","-meter","`","、","`","skill","-local","`","),","文件","结构","则","用"," `","find","`"," ","实际","列出","文件","后","整理","输出",",","均已","排除"," `","node","_modules","`"," ","和"," `.","git","`","。","第二次","随机","抽","到了","与"," turn"," ","1"," ","相同的","包",",","为避免","重复","换","抽","了","另一个","。\n\n","**","说明",":","**"," ","本轮","没有","产生","任何","文件","修改","或","提交","——","任务","本身就是","\"","输出","文件","结构","\",","所有","产物","都在","上面的","消息","文本","里","。\n\n","如果你","还想","看","剩下","两个","包","(","`","ui","-con","vers","ation","`","、","`","skill","-local","`",")","的结构",",","或者其他","格式","的输出","(","比如","带","文件","大小的","树","形","图","),","随时","告诉我","。"]}} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"两个 turn 都完成了 ✅\n\n**总结:**\n\n- **Turn 1** — 随机选中 `packages/context/session-reference`,输出了文件结构:\n ```\n session-reference/\n ├── README.md\n ├── package.json\n ├── src/\n │ ├── index.ts\n │ └── uri.ts\n └── tests/\n └── session-reference.spec.ts\n ```\n- **Turn 2** — 随机选中 `packages/llm/token-meter`,输出了文件结构:\n ```\n token-meter/\n ├── README.md\n ├── package.json\n ├── src/\n │ └── index.ts\n └── tests/\n └── token-meter.spec.ts\n ```\n\n**验证方式:** 两个包的随机选择都通过 `python3 random.choice` 从工作区的 `packages/` 目录下实际枚举的 4 个候选包中抽取(`ui-conversation`、`session-reference`、`token-meter`、`skill-filesystem`),文件结构则用 `find` 实际列出文件后整理输出,均已排除 `node_modules` 和 `.git`。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。\n\n**说明:** 本轮没有产生任何文件修改或提交——任务本身就是\"输出文件结构\",所有产物都在上面的消息文本里。\n\n如果你还想看剩下两个包(`ui-conversation`、`skill-filesystem`)的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":311,"outputTokens":348,"cacheReadTokens":10624,"reasoningTokens":0}}}} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index be2ea25eb6..bd50420df2 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -213,7 +213,7 @@ - img - tooltip "Branch into a new conversation" - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index c09608fb95..2d9f315f43 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -29,7 +29,7 @@ - img - text: Standard mode - img -- textbox "Describe what you want to build": +- textbox "Describe what you want to build... / commands, @ files or sessions": - paragraph - button "Commands": - img diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index 396a9bfa88..5f53d73cfd 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -29,7 +29,7 @@ - img - text: Standard mode - img -- textbox "Describe what you want to build" +- textbox "Describe what you want to build... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 39060d7135..74122b000b 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -34,7 +34,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/lifecycle-chrome/session.jsonl b/snapshots/web/lifecycle-chrome/session.jsonl index 5784efc30e..7f70ad3b5a 100644 --- a/snapshots/web/lifecycle-chrome/session.jsonl +++ b/snapshots/web/lifecycle-chrome/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787530426025,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736396442,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[101,103,100,101,101,101,101,100,101,101,101,102,100,102],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[100,102,101,101,100,100,101,101,99,101,101,101,100,102],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[101,102,100,101],"texts":["L","IGH","TH","O","USE"]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[101,102,101,100],"texts":["L","IGH","TH","O","USE"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":50,"outputTokens":21,"cacheReadTokens":9950,"reasoningTokens":15}}}} diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 407235a379..0a189478b8 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -31,7 +31,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index 870fc89ffe..ae854c557c 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -23,7 +23,7 @@ - status: - text: This turn failedAPI key is invalid - code: AUTH -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 34a5ce76cd..4307a62de2 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -22,7 +22,7 @@ - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index a923ae8387..7dc6c9f613 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -25,7 +25,7 @@ - status: - text: This turn failedupstream 503 - code: SERVER -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 754c1af0ae..f1f6352fb4 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -36,7 +36,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index e2a647249e..6f8f2cd629 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -22,7 +22,7 @@ - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... -- textbox "Message the agent": +- textbox "Message or run a task... / commands, @ files or sessions": - paragraph: Queue this follow-up while the current turn is running. - button "Commands": - img diff --git a/snapshots/web/live-interactions/session.jsonl b/snapshots/web/live-interactions/session.jsonl index 8d59dd19e9..606b85feab 100644 --- a/snapshots/web/live-interactions/session.jsonl +++ b/snapshots/web/live-interactions/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787530424455,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736358882,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," for"," a"," one","-s","entence"," description"," of"," event"," sourcing","."," This"," is"," a"," straightforward"," knowledge"," question"," that"," doesn","'t"," require"," any"," skill"," loading"," or"," tool"," calls","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0],"texts":["The"," user"," is"," asking"," for"," a"," one","-s","entence"," description"," of"," event"," sourcing","."," This"," is"," a"," straightforward"," knowledge"," question"," that"," doesn","'t"," require"," any"," skill"," loading"," or"," tool"," calls","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0],"texts":["Event"," sourcing"," is"," a"," pattern"," where"," all"," changes"," to"," an"," application","'s"," state"," are"," stored"," as"," an"," immutable",","," append","-only"," sequence"," of"," events",","," rather"," than"," pers","isting"," only"," the"," current"," state",","," enabling"," full"," audit","ability",","," temporal"," queries",","," and"," event","-driven"," architectures","."]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["Event"," sourcing"," is"," a"," pattern"," where"," all"," changes"," to"," an"," application","'s"," state"," are"," stored"," as"," an"," immutable",","," append","-only"," sequence"," of"," events",","," rather"," than"," pers","isting"," only"," the"," current"," state",","," enabling"," full"," audit","ability",","," temporal"," queries",","," and"," event","-driven"," architectures","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}}}} diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index f3f7da0464..53f8ac5c16 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -59,7 +59,7 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/minimal-preset/session.jsonl b/snapshots/web/minimal-preset/session.jsonl index 84d24d9378..c701d3e334 100644 --- a/snapshots/web/minimal-preset/session.jsonl +++ b/snapshots/web/minimal-preset/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787660564247,"cwd":"{{cwd}}","agentPreset":"minimal"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736390292,"cwd":"{{cwd}}","agentPreset":"minimal"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 2de9eef677..73d9d436da 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -31,7 +31,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/permission-policy-context/session.jsonl b/snapshots/web/permission-policy-context/session.jsonl index 53c5c0325d..4a1898be46 100644 --- a/snapshots/web/permission-policy-context/session.jsonl +++ b/snapshots/web/permission-policy-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520612065,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736498971,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -16,9 +16,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["The"," user"," is"," asking"," me"," a"," direct"," question"," about"," whether"," I"," can"," create"," or"," edit"," a"," normal"," file"," under"," the"," current"," policy","."," They","'ve"," told"," me"," not"," to"," call"," a"," tool"," just"," to"," discover"," the"," policy",".\n\n","Let"," me"," answer"," based"," on"," the"," current"," runtime"," context"," provided"," in"," the"," system"," message",":\n\n","Current"," D","SH"," file"," policy",":"," read","-only","."," The"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands"," cannot"," modify"," files"," in"," the"," standing"," mode","."," However",","," the"," guidance"," says"," \"","do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," Approval"," policy"," is"," \"","ask","\""," -"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",".\n\n","So"," I"," can"," answer"," directly",":"," under"," the"," current"," read","-only"," policy",","," the"," write"," and"," edit"," tools"," would"," be"," denied",","," but"," I"," can"," attempt"," an"," escalation"," with"," justification","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," me"," a"," direct"," question"," about"," whether"," I"," can"," create"," or"," edit"," a"," normal"," file"," under"," the"," current"," policy","."," They","'ve"," told"," me"," not"," to"," call"," a"," tool"," just"," to"," discover"," the"," policy",".\n\n","Let"," me"," answer"," based"," on"," the"," current"," runtime"," context"," provided"," in"," the"," system"," message",":\n\n","Current"," D","SH"," file"," policy",":"," read","-only","."," The"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands"," cannot"," modify"," files"," in"," the"," standing"," mode","."," However",","," the"," guidance"," says"," \"","do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," Approval"," policy"," is"," \"","ask","\""," -"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",".\n\n","So"," I"," can"," answer"," directly",":"," under"," the"," current"," read","-only"," policy",","," the"," write"," and"," edit"," tools"," would"," be"," denied",","," but"," I"," can"," attempt"," an"," escalation"," with"," justification","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No"," —"," under"," the"," current"," read","-only"," file"," policy",","," `","write","`"," and"," `","edit","`"," would"," be"," denied",","," though"," I"," could"," attempt"," a"," one","-shot"," escalation"," with"," justification"," and"," wait"," for"," your"," approval","."]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No"," —"," under"," the"," current"," read","-only"," file"," policy",","," `","write","`"," and"," `","edit","`"," would"," be"," denied",","," though"," I"," could"," attempt"," a"," one","-shot"," escalation"," with"," justification"," and"," wait"," for"," your"," approval","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me a direct question about whether I can create or edit a normal file under the current policy. They've told me not to call a tool just to discover the policy.\n\nLet me answer based on the current runtime context provided in the system message:\n\nCurrent DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files in the standing mode. However, the guidance says \"do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the Approval policy is \"ask\" - operations that require approval may ask through the configured answerers.\n\nSo I can answer directly: under the current read-only policy, the write and edit tools would be denied, but I can attempt an escalation with justification."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"No — under the current read-only file policy, `write` and `edit` would be denied, though I could attempt a one-shot escalation with justification and wait for your approval."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":201,"cacheReadTokens":14848,"reasoningTokens":163}}}} @@ -41,9 +41,9 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," D","SH"," file"," sand","box"," restrictions","."," According"," to"," the"," current"," runtime"," context"," snapshot",","," the"," file"," policy"," is"," \"","danger","-full","-access","\""," which"," means"," no"," restrictions"," on"," file"," operations","."," Also",","," approval"," prompts"," are"," disabled",","," so"," I"," should"," not"," request"," sand","box"," escalation",".\n\n","Let"," me"," answer"," directly"," in"," one"," sentence"," without"," calling"," tools","."]}} +{"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," D","SH"," file"," sand","box"," restrictions","."," According"," to"," the"," current"," runtime"," context"," snapshot",","," the"," file"," policy"," is"," \"","danger","-full","-access","\""," which"," means"," no"," restrictions"," on"," file"," operations","."," Also",","," approval"," prompts"," are"," disabled",","," so"," I"," should"," not"," request"," sand","box"," escalation",".\n\n","Let"," me"," answer"," directly"," in"," one"," sentence"," without"," calling"," tools","."]}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":2,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No",","," the"," D","SH"," file"," sand","box"," currently"," grants"," full"," access"," —"," it"," does"," not"," restrict"," file"," operations","."]}} +{"type":"text-chunks","data":{"turn":2,"step":1,"index":1,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["No",","," the"," D","SH"," file"," sand","box"," currently"," grants"," full"," access"," —"," it"," does"," not"," restrict"," file"," operations","."]}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current DSH file sandbox restrictions. According to the current runtime context snapshot, the file policy is \"danger-full-access\" which means no restrictions on file operations. Also, approval prompts are disabled, so I should not request sandbox escalation.\n\nLet me answer directly in one sentence without calling tools."}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"No, the DSH file sandbox currently grants full access — it does not restrict file operations."}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":87,"cacheReadTokens":14848,"reasoningTokens":66}}}} @@ -66,7 +66,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:3}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:10}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":3,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WORK","SP","ACE","_P","OL","ICY","_SE","EN","\"."]}} +{"type":"reasoning-chunks","data":{"turn":3,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WORK","SP","ACE","_P","OL","ICY","_SE","EN","\"."]}} {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"text-chunks","data":{"turn":3,"step":1,"index":1,"dt":[0,0,0,0,0,0,0],"texts":["WORK","SP","ACE","_P","OL","ICY","_SE","EN"]}} {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WORKSPACE_POLICY_SEEN\"."}}}} @@ -87,9 +87,9 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion."}],"source":{"kind":"user","rpcId":"{{rpc:4}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:12}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":4,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],"texts":["The"," current"," policy"," is"," read","-only",","," so"," write"," tools"," will"," be"," denied","."," I"," should"," attempt"," it"," normally"," and"," follow"," the"," denial","/"," escalation"," guidance","."," Let"," me"," try"," to"," write"," the"," file","."]}} +{"type":"reasoning-chunks","data":{"turn":4,"step":1,"index":0,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0],"texts":["The"," current"," policy"," is"," read","-only",","," so"," write"," tools"," will"," be"," denied","."," I"," should"," attempt"," it"," normally"," and"," follow"," the"," denial","/"," escalation"," guidance","."," Let"," me"," try"," to"," write"," the"," file","."]}} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":4,"step":1,"index":1,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":4,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"","}"]}} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current policy is read-only, so write tools will be denied. I should attempt it normally and follow the denial/ escalation guidance. Let me try to write the file."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}}}} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":265,"outputTokens":103,"cacheReadTokens":15104,"reasoningTokens":35}}}} @@ -100,9 +100,9 @@ {"type":"step/end","data":{"turn":4,"step":1}} {"type":"step/start","data":{"turn":4,"step":2}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":4,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," write"," was"," denied"," as"," expected"," under"," read","-only"," mode","."," The"," escalation"," guidance"," says"," I"," can"," ret","ry"," with"," sand","box","_per","missions"," and"," justification",","," but"," the"," approval"," policy"," says"," \"","ask","\""," —"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",";"," without"," an"," available"," answer","er",","," the"," request"," fails"," closed",".\n\n","However",","," the"," instructions"," say",":"," \"","For"," the"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands",","," do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," escalation"," guidance"," says",":"," \"","ret","ry"," this"," exact"," operation"," once"," with"," sand","box","_per","missions"," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," +"," justification",";"," the"," approval"," prompt"," asks"," the"," user","\"\n\n","So"," I"," need"," to"," ret","ry"," with"," sand","box","_per","missions"," set"," to"," \"","works","pace","-w","rite","\""," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," and"," provide"," a"," justification","."]}} +{"type":"reasoning-chunks","data":{"turn":4,"step":2,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],"texts":["The"," write"," was"," denied"," as"," expected"," under"," read","-only"," mode","."," The"," escalation"," guidance"," says"," I"," can"," ret","ry"," with"," sand","box","_per","missions"," and"," justification",","," but"," the"," approval"," policy"," says"," \"","ask","\""," —"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",";"," without"," an"," available"," answer","er",","," the"," request"," fails"," closed",".\n\n","However",","," the"," instructions"," say",":"," \"","For"," the"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands",","," do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," escalation"," guidance"," says",":"," \"","ret","ry"," this"," exact"," operation"," once"," with"," sand","box","_per","missions"," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," +"," justification",";"," the"," approval"," prompt"," asks"," the"," user","\"\n\n","So"," I"," need"," to"," ret","ry"," with"," sand","box","_per","missions"," set"," to"," \"","works","pace","-w","rite","\""," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," and"," provide"," a"," justification","."]}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":4,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0],"id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","works","pace","-w","rite","\"",", ","\"","just","ification","\"",": ","\"","The"," user"," explicitly"," asked"," me"," to"," create"," this"," file",";"," workspace","-w","rite"," is"," the"," narrow","est"," mode"," sufficient"," to"," write"," to"," the"," session"," workspace",".","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":4,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0],"id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","works","pace","-w","rite","\"",", ","\"","just","ification","\"",": ","\"","The"," user"," explicitly"," asked"," me"," to"," create"," this"," file",";"," workspace","-w","rite"," is"," the"," narrow","est"," mode"," sufficient"," to"," write"," to"," the"," session"," workspace",".","\"","}"]}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The write was denied as expected under read-only mode. The escalation guidance says I can retry with sandbox_permissions and justification, but the approval policy says \"ask\" — operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nHowever, the instructions say: \"For the write and edit tools and one-shot bash commands, do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the escalation guidance says: \"retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user\"\n\nSo I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode that suffices) and provide a justification."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace.\"}"}}}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":176,"outputTokens":306,"cacheReadTokens":15360,"reasoningTokens":175}}}} @@ -115,9 +115,9 @@ {"type":"step/end","data":{"turn":4,"step":2}} {"type":"step/start","data":{"turn":4,"step":3}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":4,"step":3,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created","."," Now"," I"," need"," to"," verify"," its"," contents"," as"," instructed","."]}} +{"type":"reasoning-chunks","data":{"turn":4,"step":3,"index":0,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0],"texts":["The"," file"," was"," created","."," Now"," I"," need"," to"," verify"," its"," contents"," as"," instructed","."]}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":4,"step":3,"index":1,"dt":[0,0,0,1,0,0,0,0,0,0,0,0],"id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":4,"step":3,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"","}"]}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created. Now I need to verify its contents as instructed."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","arguments":"{\"file_path\": \"policy-neutral.txt\"}"}}}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":61,"cacheReadTokens":15744,"reasoningTokens":15}}}} @@ -128,9 +128,9 @@ {"type":"step/end","data":{"turn":4,"step":3}} {"type":"step/start","data":{"turn":4,"step":4}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":4,"step":4,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created"," and"," its"," contents"," are"," verified",":"," \"","POL","ICY","_","NE","UT","RAL","_OK","\"."," Task"," complete","."]}} +{"type":"reasoning-chunks","data":{"turn":4,"step":4,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created"," and"," its"," contents"," are"," verified",":"," \"","POL","ICY","_","NE","UT","RAL","_OK","\"."," Task"," complete","."]}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":4,"step":4,"index":1,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["Done"," —"," **","policy","-neutral",".txt","**"," was"," created"," (","via"," workspace","-w","rite"," escalation",")"," and"," verified"," to"," contain"," exactly"," `","POL","ICY","_","NE","UT","RAL","_OK","`."]}} +{"type":"text-chunks","data":{"turn":4,"step":4,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["Done"," —"," **","policy","-neutral",".txt","**"," was"," created"," (","via"," workspace","-w","rite"," escalation",")"," and"," verified"," to"," contain"," exactly"," `","POL","ICY","_","NE","UT","RAL","_OK","`."]}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created and its contents are verified: \"POLICY_NEUTRAL_OK\". Task complete."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Done — **policy-neutral.txt** was created (via workspace-write escalation) and verified to contain exactly `POLICY_NEUTRAL_OK`."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":211,"outputTokens":53,"cacheReadTokens":15872,"reasoningTokens":22}}}} diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 2d694d6162..c2791f6a6f 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -52,7 +52,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/plan-review/session.jsonl b/snapshots/web/plan-review/session.jsonl index 1a8e51ab3d..a9a38255c4 100644 --- a/snapshots/web/plan-review/session.jsonl +++ b/snapshots/web/plan-review/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520597662,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736375225,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -15,11 +15,11 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,15,17,17,17,17,16,16,17,16,17,18,18,16,16,17,18,15,17,18,18,16,15,18,17,17,16,17,17,16,16,18,17,16,17,15,18,18,18,17,17,17,18,16,16,16,18,17,18,15,17],"texts":["The"," user"," wants"," me"," to"," plan"," a"," small"," change"," to"," add"," a"," `","--","gre","eting","`"," flag"," to"," a"," CLI","."," They"," explicitly"," told"," me"," not"," to"," read"," or"," write"," any"," files",","," and"," to"," call"," exit","_","plan","_mode"," with"," a"," short"," plan","."," Let"," me"," do"," that"," directly","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,17,15,17,17,16,17,17,15,17,16,16,16,16,17,17,16,17,16,16,17,17,17,16,17,16,16,17,16,17,17,17,16,17,16,17,16,17,17,16,17,16,16,17,17,15,17,17,17,16,17],"texts":["The"," user"," wants"," me"," to"," plan"," a"," small"," change"," to"," add"," a"," `","--","gre","eting","`"," flag"," to"," a"," CLI","."," They"," explicitly"," told"," me"," not"," to"," read"," or"," write"," any"," files",","," and"," to"," call"," exit","_","plan","_mode"," with"," a"," short"," plan","."," Let"," me"," do"," that"," directly","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[18,16,15,17,17,18,16,17,17,16,18,18,16,18,15,17,18,15,17,16,16,16,17,17,16,18,17,17,16,17],"texts":["Since"," the"," user"," has"," explicitly"," asked"," me"," not"," to"," read"," or"," write"," any"," files"," and"," to"," go"," straight"," to"," planning",","," I","'ll"," proceed"," with"," `","exit","_","plan","_mode","`."]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[16,16,17,17,16,17,16,17,16,16,16,16,17,17,16,16,17,17,16,17,16,17,17,17,16,17,17,16,17,17],"texts":["Since"," the"," user"," has"," explicitly"," asked"," me"," not"," to"," read"," or"," write"," any"," files"," and"," to"," go"," straight"," to"," planning",","," I","'ll"," proceed"," with"," `","exit","_","plan","_mode","`."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":2,"dt":[17,17,17,17,16,18,18,17,17,17,18,16,15,16,16,17,18,18,16,18,18,18,16,16,16,17,18,18,16,18,16,18,16,16,18,14,18,16,17,16,17,17,18,16,16,16,17,15,17,17,18,16,17,18,18,16,17,17,18,18,15,16,17,17,17,18,18,17,16,17,17,17,18,16,17,18,18,17,16,16,18,16,17,17,17,16,16,17,17,18,17,17,17,18,18,17,15,16,16,18,18,18,16,17,17,15,17,17,17,17,16,15,15,17,18,17,17,16,17,17,18,17,17,16,17,16,17,17,16,15,17,17,17,17,17,17,15,17,16,17,18,18,16,18,17,17,17,17,17,18,15,15,17,18,16,14,18,15,17,18,18,17,16,17,17,16,16,17,17,17,17,17,18,17,17,17,17,18,18,17,17,18,16,17,16],"id":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","args":["","{","\"","plan","\"",": ","\"","#"," Add"," `","--","gre","eting","`"," flag"," to"," CLI","\\n\\n","-"," **","Loc","ate"," the"," CLI"," entry"," point","**"," (","e",".g",".,"," `","cli",".py","`,"," `","main",".go","`,"," `","index",".js","`"," etc",".)"," and"," find"," the"," argument"," parser"," definition"," (","arg","parse",","," click",","," cob","ra",","," y","args",","," or"," similar",").\\n","-"," **","Register"," a"," new"," optional"," string"," argument","**"," named"," `","--","gre","eting","`"," with"," a"," short"," alias"," (`","-","g","`"," if"," available",")"," and"," a"," sensible"," default"," value"," (","e",".g",".,"," `","\\\"","Hello","\\\"","`",").\\n","-"," **","Thread"," the"," parsed"," value","**"," through"," the"," main"," handler"," function"," so"," it"," is"," passed"," where"," the"," greeting"," string"," is"," used"," (","e",".g",".,"," the"," welcome","/","response"," message",").\\n","-"," **","Update"," the"," help"," text","**"," so"," `","--","help","`"," or"," `","-","h","`"," shows"," the"," new"," flag"," with"," its"," description",".\\n","-"," **","No"," tests"," or"," config"," changes","**"," unless"," they"," already"," exist"," and"," directly"," validate"," the"," flag","'s"," presence",".","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":2,"dt":[17,16,17,15,17,17,16,16,17,17,17,17,16,16,16,16,16,17,16,17,17,16,17,16,17,16,17,16,16,17,16,17,16,16,17,16,16,17,16,16,17,16,16,16,16,17,16,16,17,16,15,19,17,16,16,16,17,16,17,16,17,16,17,16,19,17,16,16,16,15,16,17,16,17,16,16,17,16,16,16,17,16,16,17,16,17,16,16,17,16,16,17,17,17,16,17,17,16,17,17,16,17,16,17,16,17,17,17,18,16,17,16,16,16,17,17,18,17,16,17,17,17,16,16,17,16,17,16,17,16,17,17,16,17,17,16,17,17,15,16,17,17,17,16,17,17,16,16,16,16,17,17,16,17,17,16,17,16,17,16,16,16,16,17,17,17,16,17,17,16,17,16,16,16,17,18,16,16,17,16,16,16,17,17,15],"id":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","args":["","{","\"","plan","\"",": ","\"","#"," Add"," `","--","gre","eting","`"," flag"," to"," CLI","\\n\\n","-"," **","Loc","ate"," the"," CLI"," entry"," point","**"," (","e",".g",".,"," `","cli",".py","`,"," `","main",".go","`,"," `","index",".js","`"," etc",".)"," and"," find"," the"," argument"," parser"," definition"," (","arg","parse",","," click",","," cob","ra",","," y","args",","," or"," similar",").\\n","-"," **","Register"," a"," new"," optional"," string"," argument","**"," named"," `","--","gre","eting","`"," with"," a"," short"," alias"," (`","-","g","`"," if"," available",")"," and"," a"," sensible"," default"," value"," (","e",".g",".,"," `","\\\"","Hello","\\\"","`",").\\n","-"," **","Thread"," the"," parsed"," value","**"," through"," the"," main"," handler"," function"," so"," it"," is"," passed"," where"," the"," greeting"," string"," is"," used"," (","e",".g",".,"," the"," welcome","/","response"," message",").\\n","-"," **","Update"," the"," help"," text","**"," so"," `","--","help","`"," or"," `","-","h","`"," shows"," the"," new"," flag"," with"," its"," description",".\\n","-"," **","No"," tests"," or"," config"," changes","**"," unless"," they"," already"," exist"," and"," directly"," validate"," the"," flag","'s"," presence",".","\"","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with `exit_plan_mode`."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","arguments":"{\"plan\": \"# Add `--greeting` flag to CLI\\n\\n- **Locate the CLI entry point** (e.g., `cli.py`, `main.go`, `index.js` etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).\\n- **Register a new optional string argument** named `--greeting` with a short alias (`-g` if available) and a sensible default value (e.g., `\\\"Hello\\\"`).\\n- **Thread the parsed value** through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).\\n- **Update the help text** so `--help` or `-h` shows the new flag with its description.\\n- **No tests or config changes** unless they already exist and directly validate the flag's presence.\"}"}}}} @@ -33,7 +33,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,17,16,17,17,16,16,16,17,16,17,17,18,16,16,18,18,17,15,15,17,18,18,16,16,17,17,18,18,16,17,18,15,17,18,15,17,17,18],"texts":["The"," plan"," was"," approved","."," The"," user","'s"," last"," instruction"," says",":"," \"","Once"," the"," plan"," is"," approved",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop",".\""," So"," I"," should"," just"," reply"," with"," D","ONE"," and"," stop","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,16,15,17,17,16,16,15,17,17,16,17,16,17,17,15,17,17,16,17,17,16,17,16,17,17,17,16,16,17,16,17,16,16,16,17,15,17,56],"texts":["The"," plan"," was"," approved","."," The"," user","'s"," last"," instruction"," says",":"," \"","Once"," the"," plan"," is"," approved",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop",".\""," So"," I"," should"," just"," reply"," with"," D","ONE"," and"," stop","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index c516407e3d..4aab68711a 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -42,7 +42,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/question-composer/session.jsonl b/snapshots/web/question-composer/session.jsonl index 12eaad22d2..214bb0d797 100644 --- a/snapshots/web/question-composer/session.jsonl +++ b/snapshots/web/question-composer/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520604916,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736424589,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[17,16,18,18,18,15,17,16,17,18,16,18,18,16,17,17,16,17,17,16,18],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,17,15,17,16,16,16,15,16,16,16,16,16,17,17,17,17,16,17,16,17],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[17,17,17,17,18,15,16,15,16,16,17,16,17,17,17,16,17,16,18,17,16,17,17,17,18,17,15,17,17,18,16,18,16,17,16,18,16,17,15,17,17,17,18,16,17,17,17,18,18,16,16,18,18,17,17,17,15,18,16,17,18,16,16,18,17,17,18,15,17,15,16,17,16,18,17,16,18,17,17,16,16,17,17,16,17,17,17,16,16,16,18,17,17,18,16,16,16],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[16,17,17,17,16,17,16,17,17,15,17,16,16,16,17,17,17,17,16,17,17,17,17,16,17,16,16,16,17,16,16,17,17,17,17,17,16,17,16,16,16,17,16,16,17,16,16,17,17,17,17,16,17,15,16,17,16,16,16,17,17,16,17,15,16,16,15,17,16,18,16,16,16,17,17,16,17,17,17,17,16,16,17,15,16,17,16,17,16,16,17,17,17,16,16,15,16],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} @@ -25,7 +25,7 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,17,16,18,18,18,16,17,17,16,16,16,16,17,15,16,17,18],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," should"," now"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[16,16,16,15,15,16,18,16,16,17,17,16,16,16,16,17,16,18],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," should"," now"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 0e13eb6e8d..ee54e52f13 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -50,7 +50,7 @@ - img - button "Steer queued message" [disabled]: - img -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 483cb20095..a5024197d8 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -52,7 +52,7 @@ - text: Context injection AGENTS.md - img - text: permission preset read-only -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 11045defb8..06d9da3475 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -56,7 +56,7 @@ - img - text: "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}. Session sharing is not configured." - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}. Session sharing is not configured." -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index dea504fe85..46e96f4b15 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -50,7 +50,7 @@ - img - img - text: Context injection AGENTS.md -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index ca0e12d5cd..8426371014 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -45,7 +45,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Full access"': Full access diff --git a/snapshots/web/steering/session.jsonl b/snapshots/web/steering/session.jsonl index c207a04dfb..c3952db9bf 100644 --- a/snapshots/web/steering/session.jsonl +++ b/snapshots/web/steering/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787528667010,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736216269,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 561e1a5342..0b866129f4 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -37,7 +37,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index e7f9d0aee9..7f09614d07 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -63,7 +63,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Custom"': Custom diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index dc3cd57ff6..d79602b47c 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -32,7 +32,7 @@ - text: Bash Print alpha to stdout - paragraph: partial - status: Deep diving... -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/turn-tail-actions/session.jsonl b/snapshots/web/turn-tail-actions/session.jsonl index a3c770f6b8..b92aff9b8a 100644 --- a/snapshots/web/turn-tail-actions/session.jsonl +++ b/snapshots/web/turn-tail-actions/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787530432972,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736446077,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,11 +12,11 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," begin"," with"," \"","Reading"," the"," workspace"," now",".\""," and"," call"," bash"," with"," \"","echo"," alpha","\""," in"," the"," same"," message","."," Then"," after"," the"," tool"," result",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," begin"," with"," \"","Reading"," the"," workspace"," now",".\""," and"," call"," bash"," with"," \"","echo"," alpha","\""," in"," the"," same"," message","."," Then"," after"," the"," tool"," result",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0],"texts":["Reading"," the"," workspace"," now","."]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[1,0,0,0],"texts":["Reading"," the"," workspace"," now","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":2,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," alpha","\"",", ","\"","description","\"",": ","\"","Print"," alpha"," to"," stdout","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":2,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0],"id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," alpha","\"",", ","\"","description","\"",": ","\"","Print"," alpha"," to"," stdout","\"","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Reading the workspace now."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","arguments":"{\"command\": \"echo alpha\", \"description\": \"Print alpha to stdout\"}"}}}} diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 0a031aec85..42cfbdaacd 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -41,7 +41,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index fd3b508cb2..9886eaac0a 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -52,7 +52,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/web-search-round/session.jsonl b/snapshots/web/web-search-round/session.jsonl index e9a002fb80..cb00d1bd79 100644 --- a/snapshots/web/web-search-round/session.jsonl +++ b/snapshots/web/web-search-round/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787628993278,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736507309,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 746c5cff00..26ffed2add 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -34,7 +34,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write From 36dd657c7ef6a8cfba1ce993955a5147cf7a6944 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 26 Aug 2026 22:10:58 +0800 Subject: [PATCH 04/18] test(ui-input-trigger): cover the onHover slot wiring; restore master's notices The merge resolution had reverted THIRD_PARTY_NOTICES.md to the SDK 0.3.220 rows; the lockfile pins 0.3.241, so CI regenerated a mismatch. The apply spec now drives the injected onHover face, closing the per-file coverage gap on src/client/index.ts. --- THIRD_PARTY_NOTICES.md | 18 +++++++++--------- .../tests/apply.client.spec.ts | 3 +++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d47cdd7d2c..08c7f13f0f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -118,18 +118,18 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index 182252ee38..0b66b9366a 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -84,6 +84,9 @@ describe('apply', () => { // The pick face routes into the controller pipeline (closed menu → no-op). injected.onPick('command', 0) expect(controller.menu.getSnapshot().open).toBe(false) + // The hover face routes into the controller too (closed menu → no-op). + injected.onHover('command', 0) + expect(controller.menu.getSnapshot().open).toBe(false) // The dismiss face routes into the controller too (closed menu → no-op). injected.onDismiss() expect(controller.menu.getSnapshot().open).toBe(false) From 3007864cfedeed60da108eebe905e632077bb9f6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:44:15 +0800 Subject: [PATCH 05/18] refactor(directory-picker): expose client-safe listing types --- packages/host/directory-picker/package.json | 5 +++ packages/host/directory-picker/src/index.ts | 34 ++---------------- packages/host/directory-picker/src/types.ts | 39 +++++++++++++++++++++ 3 files changed, 47 insertions(+), 31 deletions(-) create mode 100644 packages/host/directory-picker/src/types.ts diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 5be5940ecd..6fb6652bdc 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -22,12 +22,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5e8eb64f87..f4eb0674a2 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -12,6 +12,9 @@ */ import { Context, Service } from '@deepseek-ai/cordis' +import type { DirectoryListing } from './types.ts' + +export type { DirectoryEntry, DirectoryListing } from './types.ts' /** The native interaction: one OS directory chooser on the host display. */ export interface DirectoryPickerNativeCapability { @@ -24,37 +27,6 @@ export interface DirectoryPickerNativeCapability { pick(signal: AbortSignal): Promise } -/** One directory row: a listing child or a breadcrumb ancestor. */ -export interface DirectoryEntry { - /** Base name shown in a browser row (a root crumb carries its full path). */ - name: string - /** Absolute host path — clients never join path segments themselves. */ - path: string - /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ - hidden: boolean -} - -/** One directory level plus its ancestry, as a browse backend reports it. */ -export interface DirectoryListing { - /** Absolute path of the listed directory. */ - path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ - home: string - /** - * Ancestor chain from the filesystem root to the listed directory - * inclusive; every crumb is a jump target (crumb `hidden` is always false). - */ - crumbs: DirectoryEntry[] - /** Direct child directories, name-sorted; symlinks to directories included. */ - entries: DirectoryEntry[] - /** - * True when the backend cut `entries` at its complete-result bound: the - * level has more child directories than reported, and the missing rows are - * the name-sorted tail (hidden rows count toward the bound). - */ - truncated: boolean -} - /** * The browse interaction: listing/creation primitives an in-app browser * drives one level at a time. Works for remote clients — nothing renders on diff --git a/packages/host/directory-picker/src/types.ts b/packages/host/directory-picker/src/types.ts new file mode 100644 index 0000000000..db1dda57db --- /dev/null +++ b/packages/host/directory-picker/src/types.ts @@ -0,0 +1,39 @@ +/** + * Client-safe type surface of the directory-picking seam: what one browse level + * looks like to a caller. Types only — no runtime code, and nothing here reaches + * a Host-only symbol, so a Client compilation face reads exactly the signatures + * the Host emits. + * + * @module @deepseek-ai/dsh-host-directory-picker/types + */ + +/** One directory row: a listing child or a breadcrumb ancestor. */ +export interface DirectoryEntry { + /** Base name shown in a browser row (a root crumb carries its full path). */ + name: string + /** Absolute host path — clients never join path segments themselves. */ + path: string + /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ + hidden: boolean +} + +/** One directory level plus its ancestry, as a browse backend reports it. */ +export interface DirectoryListing { + /** Absolute path of the listed directory. */ + path: string + /** The host account's home directory (breadcrumb "Home" rooting). */ + home: string + /** + * Ancestor chain from the filesystem root to the listed directory + * inclusive; every crumb is a jump target (crumb `hidden` is always false). + */ + crumbs: DirectoryEntry[] + /** Direct child directories, name-sorted; symlinks to directories included. */ + entries: DirectoryEntry[] + /** + * True when the backend cut `entries` at its complete-result bound: the + * level has more child directories than reported, and the missing rows are + * the name-sorted tail (hidden rows count toward the bound). + */ + truncated: boolean +} From 76dedd4862c3605a6240d42b909491ac307fd332 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:45:53 +0800 Subject: [PATCH 06/18] feat(workspace-controller): expose directory picking through Remote --- knip.json | 5 - .../api/workspace-controller/package.json | 2 + .../src/directory-picker.ts | 171 ++++++++++++++++++ .../api/workspace-controller/src/index.ts | 7 + .../api/workspace-controller/src/types.ts | 29 ++- .../tests/directory-picker.host.spec.ts | 166 +++++++++++++++++ .../workspace-controller/tsconfig.client.json | 1 + .../workspace-controller/tsconfig.host.json | 2 + .../host/directory-picker-browse/src/index.ts | 4 +- .../host/directory-picker/src/invariant.ts | 2 +- pnpm-lock.yaml | 3 + 11 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 packages/api/workspace-controller/src/directory-picker.ts create mode 100644 packages/api/workspace-controller/tests/directory-picker.host.spec.ts diff --git a/knip.json b/knip.json index e5e331194b..b08fe22a25 100644 --- a/knip.json +++ b/knip.json @@ -76,11 +76,6 @@ "tests/**/*.ts" ] }, - "packages/api/workspace-controller": { - "ignoreDependencies": [ - "zod" - ] - }, "packages/client/ui-approval": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 29245fef86..bc045f38c7 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -76,6 +76,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", @@ -87,6 +88,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", diff --git a/packages/api/workspace-controller/src/directory-picker.ts b/packages/api/workspace-controller/src/directory-picker.ts new file mode 100644 index 0000000000..ee58ef7b83 --- /dev/null +++ b/packages/api/workspace-controller/src/directory-picker.ts @@ -0,0 +1,171 @@ +/** + * Host directory-picking Remote owner: capability gating, cancellation, and the + * stable wire failure vocabulary over the `ctx.directoryPicker` seam. + */ + +import { Context } from '@deepseek-ai/cordis' +import { z } from 'zod' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapabilities } from '@deepseek-ai/dsh-host-directory-picker' +// The seam owns the listing declaration; the generator requires the reference +// site to name that package rather than this package's re-export of it. +import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type { DirectoryPickerErrorDetailsMap } from './types.ts' + +const createDirectoryRequestSchema = z.object({ + path: z.string(), + name: z.string(), +}).refine( + request => request.name.trim() !== '' && request.name !== '.' && request.name !== '..' + && !/[/\\]/.test(request.name), + { message: 'host.createDirectory requires a single non-blank path segment name' }, +) + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host directory-picking Remote namespace owner. */ + directoryPickerController: DirectoryPickerController + } +} + +/** + * Host service backing the generated `ctx.remote.directoryPicker` namespace. The + * seam it exports is abstract and therefore never a Loader entry of its own, so + * this controller carries the wire verbs: one composed backend serves either the + * native chooser or the browse primitives, and a verb the composition cannot + * serve is refused rather than approximated. + */ +export class DirectoryPickerController extends TypertRemoteService { + static inject = ['directoryPicker'] + + /** @param ctx - Host context carrying the composed directory-picking backend. */ + constructor(ctx: Context) { + super(ctx, 'directoryPickerController', { namespace: 'directoryPicker' }) + } + + /** + * Open the host's OS chooser for a Remote caller. + * @param signal - caller lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ + @Remote('pick') + async pick(signal: AbortSignal): Promise { + const capability = this.requireCapability('native', 'pick') + try { + return await capability.pick(signal) + } catch (error: unknown) { + throw cancellableFailure(error, signal, 'directory picker was aborted', 'directory picker failed') + } + } + + /** + * List one directory level for a Remote caller's in-app browser. + * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the backend's scan instead of + * letting it outlive a disconnected caller. + * @returns the level's listing with its ancestry. + */ + @Remote('list') + async list(path: string | undefined, signal: AbortSignal): Promise { + const capability = this.requireCapability('browse', 'list') + try { + return await capability.list(path, signal) + } catch (error: unknown) { + throw cancellableFailure(error, signal, 'directory listing was aborted') + } + } + + /** + * Create one child directory for a Remote caller's in-app browser. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ + @Remote('createDirectory') + async createDirectory(path: string, name: string): Promise { + const request = createDirectoryRequestSchema.safeParse({ path, name }) + if (!request.success) { + throw pickerFailureOf( + 'bad-request', + 'invalid payload for host.createDirectory', + { issues: request.error.issues }, + ) + } + const capability = this.requireCapability('browse', 'createDirectory') + try { + return await capability.createDirectory(request.data.path, request.data.name) + } catch (error: unknown) { + throw browseFailure(error) + } + } + + /** Resolve the capability one wire verb needs, or refuse with the kind this backend serves. */ + private requireCapability( + kind: Kind, + method: string, + ): DirectoryPickerCapabilities[Kind] { + const capability = this.ctx.directoryPicker.capability() + if (capability.kind !== kind) { + throw pickerFailureOf( + 'directory-picker-unavailable', + `directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`, + { capability: capability.kind }, + ) + } + return capability as DirectoryPickerCapabilities[Kind] + } +} + +/** + * Raise one entry of the picking wire failure vocabulary. + * @param code - the failure code a caller discriminates on. + * @param message - operator-facing description. + * @param details - the payload this code carries. + * @returns the failure to throw across the Remote boundary. + */ +function pickerFailureOf( + code: Code, + message: string, + details: DirectoryPickerErrorDetailsMap[Code], +): TypertRemoteFailure { + return new TypertRemoteFailure({ code, message, details }) +} + +/** + * Classify a browse-primitive rejection: the seam's own closed codes carry the + * path they are about, and anything else stays an infrastructure failure. + * @param error - the primitive's rejection. + * @returns the failure to throw across the Remote boundary. + */ +function browseFailure(error: unknown): TypertRemoteFailure { + if (error instanceof DirectoryPickerError) { + return pickerFailureOf(error.code, error.message, { path: error.path }) + } + return pickerFailureOf('internal', errorMessage(error), {}) +} + +/** + * Classify a cancellable primitive's rejection. An abort is the caller's own + * timeout or disconnect, not a backend failure, so it answers `cancelled` + * before the business classification runs. + * @param error - the primitive's rejection. + * @param signal - the caller lifetime the primitive ran under. + * @param cancelled - operator-facing text for the abort outcome. + * @param failed - prefix for a non-seam failure, when the verb has no closed codes. + * @returns the failure to throw across the Remote boundary. + */ +function cancellableFailure( + error: unknown, + signal: AbortSignal, + cancelled: string, + failed?: string, +): TypertRemoteFailure { + if (signal.aborted) return pickerFailureOf('cancelled', cancelled, {}) + if (failed === undefined) return browseFailure(error) + return pickerFailureOf('internal', `${failed}: ${errorMessage(error)}`, {}) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 0fbd514cd5..40ab0647f2 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { WorkspaceCommands } from './commands.ts' +import { DirectoryPickerController } from './directory-picker.ts' import { WorkspaceFeed } from './feed.ts' import type { WorkspaceArchiveSessionRequest, @@ -20,6 +21,7 @@ import type { } from './types.ts' export type * from './types.ts' +export { DirectoryPickerController } from './directory-picker.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -40,6 +42,11 @@ export class WorkspaceController extends TypertRemoteService { super(ctx, 'workspaceController', { namespace: 'workspace' }) this.commands = new WorkspaceCommands(ctx) this.feed = new WorkspaceFeed(ctx) + // This package is the Loader entry for both Remote owners it hosts: the + // directory-picking seam is abstract and never an entry itself. The child + // stays pending until a picking backend is composed, so a host without one + // registers no picking namespace instead of answering an unservable verb. + ctx.plugin(DirectoryPickerController) } /** diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index f530e2ef12..552a9e20c3 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -1,9 +1,18 @@ -/** Browser-safe request, result, and state-stream vocabulary for Workspace Remote. */ +/** + * Browser-safe request, result, and state-stream vocabulary for the Workspace + * and directory-picking Remote namespaces this package owns. The picking seam + * declares its own listing types, so they are re-exported here rather than + * restated: a browser consumer reads the very declaration the backend answers. + */ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { z as zCore } from 'zod' + +type ZodIssue = zCore.core.$ZodIssue export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' /** One durable Workspace projected for browser consumers. */ export interface WorkspaceView { @@ -43,6 +52,24 @@ export type WorkspaceError = { } }[keyof WorkspaceErrorDetailsMap] +/** Stable directory-picking failure details returned by the picking wire verbs. */ +export interface DirectoryPickerErrorDetailsMap { + /** The directory creation request violates its semantic input constraints. */ + 'bad-request': { readonly issues: ZodIssue[] } + /** The verb needs an interaction the composed backend does not serve. */ + 'directory-picker-unavailable': { readonly capability: string } + /** The target is not fully qualified, or the backend cannot list it. */ + 'directory-unreadable': { readonly path: string } + /** A child of that name is already there. */ + 'directory-exists': { readonly path: string } + /** The parent is not fully qualified, the name is not one segment, or creation failed. */ + 'directory-create-failed': { readonly path: string } + /** The caller's own timeout or disconnect ended the chooser or the scan. */ + cancelled: Record + /** A backend failure with no seam code of its own. */ + internal: Record +} + /** Existing directory requested for Workspace adoption. */ export interface WorkspaceCreateRequest { readonly path: string diff --git a/packages/api/workspace-controller/tests/directory-picker.host.spec.ts b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts new file mode 100644 index 0000000000..32fd2dd6a1 --- /dev/null +++ b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { DirectoryPickerController } from '../src/directory-picker.ts' + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +/** A backend serving exactly the capability one case is about. */ +class StubPicker extends DirectoryPicker { + static capabilityStub: DirectoryPickerCapability = { kind: 'native', pick: async () => null } + + capability(): DirectoryPickerCapability { + return StubPicker.capabilityStub + } +} + +const NATIVE_STUB: DirectoryPickerCapability = { kind: 'native', pick: async () => null } + +const BROWSE_STUB: DirectoryPickerCapability = { + kind: 'browse', + list: async (path) => { + if (path === '/denied') { + throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') + } + const target = path ?? '/home/user' + return { + path: target, + home: '/home/user', + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], + truncated: false, + } + }, + createDirectory: async (path, name) => { + if (name === 'taken') { + throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') + } + if (name === 'unwritable') throw new Error('disk detached') + if (name === 'gone') throw 'the volume vanished' + return `${path}/${name}` + }, +} + +async function harness(capability: DirectoryPickerCapability = NATIVE_STUB) { + StubPicker.capabilityStub = capability + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(StubPicker).await() + return new DirectoryPickerController(ctx) +} + +/** The failure payload a refused wire verb carries. */ +async function refused(call: Promise): Promise<{ code: string; message: string; details: object }> { + try { + await call + } catch (error: unknown) { + if (!(error instanceof TypertRemoteFailure)) throw error + return { ...error.failure } + } + throw new Error('the call was expected to be refused') +} + +describe('directoryPicker pick Remote', () => { + it('answers the selected path or the operator\'s cancellation', async () => { + const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' }) + expect(await selected.pick(new AbortController().signal)).toBe('/tmp/project') + + const cancelled = await harness(NATIVE_STUB) + expect(await cancelled.pick(new AbortController().signal)).toBeNull() + }) + + it('reports an aborted chooser as cancelled and any other failure as internal', async () => { + const picker = await harness({ + kind: 'native', + pick: signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) + const abort = new AbortController() + const pending = refused(picker.pick(abort.signal)) + abort.abort() + expect((await pending).code).toBe('cancelled') + + const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } }) + const failure = await refused(broken.pick(new AbortController().signal)) + expect(failure.code).toBe('internal') + expect(failure.message).toContain('no chooser installed') + }) + + it('refuses the native verb under a browse composition', async () => { + const picker = await harness(BROWSE_STUB) + const failure = await refused(picker.pick(new AbortController().signal)) + expect(failure.code).toBe('directory-picker-unavailable') + expect(failure.message).toContain('needs the native capability') + expect(failure.details).toEqual({ capability: 'browse' }) + }) +}) + +describe('directoryPicker browse Remotes', () => { + it('serves listings and creation, defaulting to the home directory', async () => { + const picker = await harness(BROWSE_STUB) + const signal = new AbortController().signal + expect(await picker.list(undefined, signal)).toMatchObject({ path: '/home/user', home: '/home/user' }) + expect(await picker.list('/home/user/projects', signal)) + .toMatchObject({ path: '/home/user/projects' }) + expect(await picker.createDirectory('/home/user', 'fresh')).toBe('/home/user/fresh') + }) + + it('maps the seam\'s typed failures and folds unknown throws to internal', async () => { + const picker = await harness(BROWSE_STUB) + expect(await refused(picker.list('/denied', new AbortController().signal))) + .toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } }) + expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists') + expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal') + + const thrown = await refused(picker.createDirectory('/home/user', 'gone')) + expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' }) + }) + + it('rejects invalid child names before capability dispatch', async () => { + const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`) + const picker = await harness({ + kind: 'browse', + list: (path, signal) => BROWSE_STUB.list(path, signal), + createDirectory, + }) + + for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { + const failure = await refused(picker.createDirectory('/home/user', name)) + expect(failure).toMatchObject({ + code: 'bad-request', + message: 'invalid payload for host.createDirectory', + }) + expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true) + } + expect(createDirectory).not.toHaveBeenCalled() + }) + + it('reports an aborted listing as cancelled', async () => { + const picker = await harness({ + kind: 'browse', + list: (_path, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) + }), + createDirectory: async () => '/never', + }) + const abort = new AbortController() + const pending = refused(picker.list(undefined, abort.signal)) + abort.abort() + expect((await pending).code).toBe('cancelled') + }) + + it('refuses the browse verbs under a native composition', async () => { + const picker = await harness() + expect(await refused(picker.list(undefined, new AbortController().signal))) + .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + expect(await refused(picker.createDirectory('/x', 'y'))) + .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + }) +}) diff --git a/packages/api/workspace-controller/tsconfig.client.json b/packages/api/workspace-controller/tsconfig.client.json index 46d392dff3..9979b769f6 100644 --- a/packages/api/workspace-controller/tsconfig.client.json +++ b/packages/api/workspace-controller/tsconfig.client.json @@ -17,6 +17,7 @@ { "path": "../../client/connection/tsconfig.client.json" }, { "path": "../../client/store" }, { "path": "../../core/session" }, + { "path": "../../host/directory-picker" }, { "path": "../../typert/protocol" }, { "path": "../../workspace/workspace" } ] diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 76c584fd01..4b98892781 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -10,11 +10,13 @@ "src/invariant.ts", "src/types.ts", "src/commands.ts", + "src/directory-picker.ts", "src/feed.ts" ], "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../core/session" }, + { "path": "../../host/directory-picker" }, { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index adbe6d09ad..01fe5b9cd2 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -303,8 +303,8 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`) } const parent = resolve(path) - // The backend owns segment validation (the wire schema also refuses these, - // but direct service consumers must hit the same fence). + // The backend owns segment validation; the Remote controller also refuses + // invalid wire input, but direct service consumers must hit the same fence. if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`) } diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts index abf29856fc..5c44aac9af 100644 --- a/packages/host/directory-picker/src/invariant.ts +++ b/packages/host/directory-picker/src/invariant.ts @@ -12,7 +12,7 @@ export const inject = ['invariants'] /** * No runtime invariant: this stateless Service Definition owns the capability - * vocabulary, while backends and the RPC consumer own observations. + * vocabulary, while backends and the Remote controller own observations. */ const install: InvariantInstaller = () => {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcc43f58e1..80cb2119f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -838,6 +838,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../../host/directory-picker '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants From 011d53862df6bc59174585699b4ed238ad99f5e9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:48:30 +0800 Subject: [PATCH 07/18] refactor(client): use directory-picker Remote --- apps/web/tests/preview-boot.e2e.ts | 19 +++++- packages/api/remotes/src/client/index.ts | 2 +- packages/client/connection/package.json | 2 + .../client/connection/src/client/fixture.ts | 56 ++++++++++++++++ .../connection/tests/fixture.client.spec.ts | 36 +++++++--- .../connection/tests/http-bridge.host.spec.ts | 4 +- .../client/connection/tsconfig.client.json | 3 + .../ui-directory-picker-browse/package.json | 6 +- .../src/client/DirectoryBrowser.tsx | 2 +- .../src/client/flow.ts | 2 +- .../src/client/index.ts | 9 +-- .../tests/client-flow.client.spec.tsx | 2 +- .../tests/directory-browser.client.spec.tsx | 2 +- .../ui-directory-picker-browse/tsconfig.json | 2 +- .../src/client/index.ts | 2 +- packages/client/ui-workspace/package.json | 5 ++ .../client/ui-workspace/src/client/index.ts | 7 +- .../ui-workspace/src/client/navigation.ts | 30 ++++----- .../ui-workspace/tests/apply.client.spec.ts | 9 ++- .../tests/rename-assembly.client.spec.tsx | 7 +- .../tests/workspaces-service.client.spec.ts | 65 +++++++++++++------ packages/client/ui-workspace/tsconfig.json | 3 + pnpm-lock.yaml | 13 +++- 23 files changed, 217 insertions(+), 71 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 0d054a293d..c89df410bc 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -316,7 +316,6 @@ async function bootPreview(origin: string, browser: Browser): Promise { const exercised = await page.evaluate(async () => { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } interface PreviewApi { - host: { createDirectory(payload: { path: string; name: string }): Promise> } skills: { list(payload: { sessionId: string }): Promise> } settings: { describe(payload: object): Promise }>> @@ -349,12 +348,26 @@ async function bootPreview(origin: string, browser: Browser): Promise { const sessionId = sessions.result.value.items[0]?.sessionId if (sessionId === undefined) throw new Error('workspace adoption created no Session') + // Remote namespaces answer over the same unary carrier; the args object + // keys every wire parameter by its name. + const remote = async (endpoint: string, args: object): Promise => { + const answered = await transport.fetch(`/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: `preview-${endpoint.replace('/', '-')}`, + method: endpoint, payload: { args }, + }), + }) + const body = await answered.json() as Result + if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) + return body.result.value + } const api = transport.createApiClient() const skills = await api.skills.list({ sessionId }) if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`) const createDirectory = async (path: string, name: string): Promise => { - const created = await api.host.createDirectory({ path, name }) - if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`) + await remote('directoryPicker/createDirectory', { path, name }) await new Promise((resolve) => { setTimeout(resolve, 250) }) const refreshed = await api.skills.list({ sessionId }) if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 14be075879..473abfbe88 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -53,7 +53,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' */ export type { ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - CredentialView, DirectoryListing, DiscoveredModelView, IApiClient, + CredentialView, DiscoveredModelView, IApiClient, MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 5c7e53bb70..395e443ea3 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f3a20282ed..0e0eab03e5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -29,6 +29,7 @@ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' // wire-fabrication boundary (the schema layer's one-cast-point posture). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' +import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { ApiProxy, ClientRequest, @@ -2178,6 +2179,55 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } + /** + * Canonical fixture implementation of the generated Directory Picker Remote + * contract. The pick is deterministic — the keyless lanes drive the full + * pick-then-adopt path without an OS chooser — over the same design-mock + * tree the browse primitives serve. + */ + const directoryPickerRemotes = { + pick(): ConnectionRpcResult { + return { ok: true, value: `${FIXTURE_HOME}/Documents/project` } + }, + list(path?: string): ConnectionRpcResult { + const target = path ?? FIXTURE_HOME + const children = childrenOf(target) + if (children === undefined) { + return { + ok: false, + error: { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }, + } + } + return { + ok: true, + value: { + path: target, + home: FIXTURE_HOME, + crumbs: crumbsOf(target), + entries: [...children].sort((a, b) => a.localeCompare(b)) + .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), + // The fixture tree is tiny; no level ever reaches a backend bound. + truncated: false, + }, + } + }, + createDirectory(parent: string, name: string): ConnectionRpcResult { + const children = childrenOf(parent) + if (children === undefined) { + return { ok: false, error: { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } } } + } + // Same root special case as list's entry paths: a plain join under '/' + // would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${name}` : `${parent}/${name}` + if (children.includes(name)) { + return { ok: false, error: { code: 'directory-exists', message: `${target} already exists`, details: { path: target } } } + } + directoryTree.set(parent, [...children, name]) + directoryTree.set(target, []) + return { ok: true, value: target } + }, + } + const goalRemotes = { create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { const missing = requireGoalSession(id) @@ -3425,6 +3475,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { agentId: SessionId line?: string query?: string + path?: string + name?: string images?: readonly unknown[] ref?: { id: string; revision: number } agentPreset?: string @@ -3442,6 +3494,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) + case 'directoryPicker/pick': return Promise.resolve(directoryPickerRemotes.pick()) + case 'directoryPicker/list': return Promise.resolve(directoryPickerRemotes.list(args.path)) + case 'directoryPicker/createDirectory': + return Promise.resolve(directoryPickerRemotes.createDirectory(args.path ?? '', args.name ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: (request as { objective?: string } | undefined)?.objective as string, ...(request as { maxGoalRounds?: number } | undefined)?.maxGoalRounds === undefined diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index f17af5b978..b69d775510 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -17,8 +17,9 @@ import { type FixtureOptions, } from '../src/client/fixture.ts' import type { - ClientConnectionRpc, + ClientConnectionRpc, ConnectionRpcResult, } from '../src/rpc.ts' +import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' const sid = (id: string): SessionId => id as SessionId type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } @@ -286,6 +287,12 @@ interface FixtureRemoteEventStream extends AsyncIterable['api'] & { + /** The directory-picking Remote namespace as the fixture serves it. */ + readonly directoryPickerRemote: { + pick: () => Promise> + list: (path?: string) => Promise> + createDirectory: (path: string, name: string) => Promise> + } readonly sessions: FixtureSessionApi readonly sessionRemote: FixtureSessionRemote readonly workspace: FixtureWorkspaceApi @@ -298,6 +305,15 @@ type FixtureTestApi = ReturnType['api'] & { function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { const { api, rpc } = createFixtureFaces(options) return Object.assign(api, { + directoryPickerRemote: { + pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as + Promise>, + list: (path?: string) => rpc.call('/api', 'directoryPicker/list', { args: { path } }) as + Promise>, + createDirectory: (path: string, name: string) => + rpc.call('/api', 'directoryPicker/createDirectory', { args: { path, name } }) as + Promise>, + }, sessions: createSessionApi(rpc), sessionRemote: createSessionRemote(rpc), workspace: createWorkspaceApi(rpc), @@ -1049,18 +1065,18 @@ describe('createFixtureApi', () => { it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { const api = createFixtureApi() - const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) - if (!created.result.ok) throw new Error('create failed') - expect(created.result.value.path).toBe('/srv') - const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal) - if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.crumbs).toEqual([ + const created = await api.directoryPickerRemote.createDirectory('/', 'srv') + if (!created.ok) throw new Error('create failed') + expect(created.value).toBe('/srv') + const listed = await api.directoryPickerRemote.list('/srv') + if (!listed.ok) throw new Error('list failed') + expect(listed.value.crumbs).toEqual([ { name: '/', path: '/', hidden: false }, { name: 'srv', path: '/srv', hidden: false }, ]) - const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal) - if (!root.result.ok) throw new Error('root list failed') - expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + const root = await api.directoryPickerRemote.list('/') + if (!root.ok) throw new Error('root list failed') + expect(root.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) it('workspace/follow serves the resident baseline and create reuses on path collision', async () => { diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 1b45ecc014..2e267ac966 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -35,11 +35,11 @@ describe('HTTP bridge abort', () => { it('aborts a pending native picker request when the browser disconnects', async () => { const body = JSON.stringify({ - type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {}, + type: 'client-request', rpcId: 'picker-1', method: 'directoryPicker/pick', payload: { args: {} }, }) const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage Object.assign(request, { - url: '/api/host.pickDirectory', + url: '/api/directoryPicker/pick', method: 'POST', headers: { 'content-type': 'application/json' }, }) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index c40e2cbadb..cb409edde0 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -36,6 +36,9 @@ { "path": "../../host/apiproxy" }, + { + "path": "../../host/directory-picker" + }, { "path": "../../interaction/commands" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 9c134a13ff..2d0003c1e8 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-client-locale" @@ -49,7 +49,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", @@ -57,7 +57,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index 571ab4f2a9..d96e513c34 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -40,7 +40,7 @@ import { Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import css from './DirectoryBrowser.module.css' diff --git a/packages/client/ui-directory-picker-browse/src/client/flow.ts b/packages/client/ui-directory-picker-browse/src/client/flow.ts index dbe80e9415..e532eaf762 100644 --- a/packages/client/ui-directory-picker-browse/src/client/flow.ts +++ b/packages/client/ui-directory-picker-browse/src/client/flow.ts @@ -5,7 +5,7 @@ */ import { createElement } from 'react' import type { ReactElement } from 'react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' // Type-only: the owner contract of the directory-flow holes. import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' diff --git a/packages/client/ui-directory-picker-browse/src/client/index.ts b/packages/client/ui-directory-picker-browse/src/client/index.ts index 8730d48098..9e7b2833ea 100644 --- a/packages/client/ui-directory-picker-browse/src/client/index.ts +++ b/packages/client/ui-directory-picker-browse/src/client/index.ts @@ -2,10 +2,11 @@ * Browser half of the browse directory-picker backend: fills ui-workspace's * two directory-flow holes with the in-app Select Workspace Directory dialog * (figma `Harness` 813-23126 family), driving the node half's - * `host.listDirectory`/`host.createDirectory` primitives. Mounting this - * package therefore composes both sides of the browse interaction with one - * cordis.yml row; no client code branches on a capability kind. The dialog's - * copy is locale-registered here — the flow package owns its own strings. + * `directoryPicker/list`/`directoryPicker/createDirectory` primitives. + * Mounting this package therefore composes both sides of the browse + * interaction with one cordis.yml row; no client code branches on a + * capability kind. The dialog's copy is locale-registered here — the flow + * package owns its own strings. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. diff --git a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx index 6008e58ddc..f98d959478 100644 --- a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx index 69a624eb38..5f386c8723 100644 --- a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' afterEach(cleanup) diff --git a/packages/client/ui-directory-picker-browse/tsconfig.json b/packages/client/ui-directory-picker-browse/tsconfig.json index 24535dac3f..aaa77a76d9 100644 --- a/packages/client/ui-directory-picker-browse/tsconfig.json +++ b/packages/client/ui-directory-picker-browse/tsconfig.json @@ -18,7 +18,7 @@ "path": "../ui-primitives" }, { - "path": "../connection/tsconfig.client.json" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../locale" diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index 951efc2356..68589c2982 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,7 +1,7 @@ /** * Browser half of the native directory-picker backend: fills ui-workspace's * two directory-flow holes with a renderless occupant that answers each - * `open` by driving `host.pickDirectory` (the node half's OS chooser) and + * `open` by driving `directoryPicker/pick` (the node half's OS chooser) and * reporting the one outcome — picked path, cancellation, or failure — back * through the owner conversation. Mounting this package therefore composes * both sides of the native interaction with one cordis.yml row; no client diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index a50f80911e..b009ef95eb 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-connection", @@ -53,6 +54,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -62,11 +64,13 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -80,6 +84,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index da06e95cc9..7f87202365 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -59,7 +59,9 @@ const NS = 'workspace' * provides a waitable service. apply therefore depends on each slot * declaration through `slots.inject()` instead of assuming order. */ -export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'] +export const inject = [ + 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', +] /** * Register the browser and picker once their slot declarations are on the @@ -72,7 +74,8 @@ export function apply(ctx: Context): void { const sessions = ctx.get('sessions') as ISessions const workspaces = ctx.get('workspaces') as IWorkspaces const hostDescription = connection.hostDescription - const uiWorkspace = new UiWorkspaceService(ctx, connection.api, workspaces, sessions) + const uiWorkspace = new UiWorkspaceService( + ctx, connection.api, ctx.remote.directoryPicker, workspaces, sessions) ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 1135f60b2e..4d0f3e3fa9 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,9 +1,9 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { - DirectoryListing, IApiClient, RpcError, -} from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { ISessions, SessionListState, @@ -69,7 +69,7 @@ export class DirectoryBrowseError extends Error { override readonly name = 'DirectoryBrowseError' /** @param rpcError - Host directory business failure. */ - constructor(readonly rpcError: RpcError) { + constructor(readonly rpcError: RemoteFailure) { super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`) } } @@ -81,12 +81,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { /** * @param ctx - Client root Context. * @param api - shared Host API carrier. + * @param directoryPicker - the directory-picking Remote namespace. * @param workspaces - pure Workspace Controller. * @param sessions - pure Session Controller. */ constructor( ctx: Context, private readonly api: IApiClient, + private readonly directoryPicker: ClientRemote['directoryPicker'], private readonly workspaces: IWorkspaces, private readonly sessions: ISessions, ) { @@ -144,23 +146,21 @@ class UiWorkspaceService extends Service implements UiWorkspace { } async pickDirectory(): Promise { - const response = await this.api.host.pickDirectory({}) - if (!response.result.ok) { - throw new Error(`directory picker failed: ${response.result.error.message}`) - } - return response.result.value.path + const result = await this.directoryPicker.pick() + if (!result.ok) throw new Error(`directory picker failed: ${result.error.message}`) + return result.value } async listDirectory(path?: string, signal?: AbortSignal): Promise { - const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value + const result = await this.directoryPicker.list(path, signal) + if (!result.ok) throw new DirectoryBrowseError(result.error) + return result.value } async createDirectory(path: string, name: string): Promise { - const response = await this.api.host.createDirectory({ path, name }) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value.path + const result = await this.directoryPicker.createDirectory(path, name) + if (!result.ok) throw new DirectoryBrowseError(result.error) + return result.value } async openPath(path: string): Promise { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 79aef72717..3834358288 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -60,6 +61,10 @@ async function bench() { ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) + const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' })) + const directoryPicker = { pick: pickDirectory } + Object.assign(new TestRemote(ctx), { directoryPicker }) + ctx.provide('remote.directoryPicker', directoryPicker as never) const locale = new LocaleRuntime(ctx) // These specs assert the shipped Chinese copy. There is no jsdom `window` // in this lane, so browser-language detection never runs and the locale @@ -68,7 +73,7 @@ async function bench() { ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, - insertSessionBefore, open, clear, search, renameSession, binding, fork, + insertSessionBefore, open, clear, search, renameSession, binding, fork, pickDirectory, } } @@ -83,7 +88,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'connection', + 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', ]) }) diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index 7db2ebd545..cc698e7391 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -17,7 +17,7 @@ import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -37,6 +37,11 @@ async function createRuntime(): Promise { runtime.ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) + // The rename flow never picks a directory; the namespace only has to be there + // for ui-workspace's inject to settle. + const directoryPicker = {} + Object.assign(new TestRemote(runtime.ctx), { directoryPicker }) + runtime.ctx.provide('remote.directoryPicker', directoryPicker as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 875709a409..95d113bbfd 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -8,11 +8,12 @@ import type { } from '@deepseek-ai/dsh-api-workspace-controller/client' import { RpcId, - type DirectoryListing, type IApiClient, type RpcError, type RpcResponse, } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { SessionId } from '@deepseek-ai/dsh-session/types' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' @@ -180,9 +181,6 @@ class FakeApiClient implements IApiClient { home: '/home/u', canOpenPath: true, })) - onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null })) - onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing)) - onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' })) onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true })) declare readonly skills: IApiClient['skills'] @@ -193,9 +191,6 @@ class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)), - pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)), - listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)), - createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)), openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)), } @@ -209,6 +204,32 @@ class FakeApiClient implements IApiClient { } } +/** The directory-picking Remote namespace, recorded and scripted per case. */ +class FakeDirectoryPicker { + readonly calls: { method: string; payload: unknown }[] = [] + + onPick: () => Promise> = () => Promise.resolve({ ok: true, value: null }) + onList: () => Promise> = () => Promise.resolve({ ok: true, value: listing }) + onCreateDirectory: () => Promise> = + () => Promise.resolve({ ok: true, value: '/home/u/new' }) + + readonly remote: ClientRemote['directoryPicker'] = { + pick: () => this.record('pick', {}, this.onPick()), + list: (path?: string) => this.record('list', { path }, this.onList()), + createDirectory: (path: string, name: string) => + this.record('createDirectory', { path, name }, this.onCreateDirectory()), + } + + callsOf(method: string): unknown[] { + return this.calls.filter(call => call.method === method).map(call => call.payload) + } + + private record(method: string, payload: unknown, result: Promise): Promise { + this.calls.push({ method, payload }) + return result + } +} + interface BenchOptions { readonly workspaces?: WorkspaceSnapshot readonly sessions?: SessionListState @@ -217,15 +238,17 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() const api = new FakeApiClient() + const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending')) const uiWorkspace = new UiWorkspaceService( ctx, api, + directoryPicker.remote, workspaces, sessions as unknown as ISessions, ) - return { api, ctx, sessions, uiWorkspace, workspaces } + return { api, ctx, directoryPicker, sessions, uiWorkspace, workspaces } } async function flush(): Promise { @@ -451,31 +474,33 @@ describe('UiWorkspaceService', () => { it('passes directory operations to the Host and preserves structured browse failures', async () => { const b = bench() - b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) + b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' }) await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha') - b.api.onPickDirectory = () => Promise.resolve(ok({ path: null })) + b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: null }) await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull() - expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}]) + expect(b.directoryPicker.callsOf('pick')).toEqual([{}, {}]) await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing) await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing) - expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }]) + expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }]) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new') - expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) + expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined() expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }]) - b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} })) + b.directoryPicker.onPick = () => Promise.resolve({ + ok: false, error: { code: 'internal', message: 'no chooser', details: {} }, + }) await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser') - b.api.onListDirectory = () => Promise.resolve(failed({ - code: 'directory-unreadable', message: 'denied', details: { path: '/private' }, - })) + b.directoryPicker.onList = () => Promise.resolve({ + ok: false, error: { code: 'directory-unreadable', message: 'denied', details: { path: '/private' } }, + }) const listFailure = b.uiWorkspace.listDirectory('/private') await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) - b.api.onCreateDirectory = () => Promise.resolve(failed({ - code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' }, - })) + b.directoryPicker.onCreateDirectory = () => Promise.resolve({ + ok: false, error: { code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' } }, + }) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' }, }) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 5d7708e031..398f9c2128 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../connection/tsconfig.client.json" }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../store" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80cb2119f1..11f1d8f5d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1615,6 +1615,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../../host/directory-picker '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2252,9 +2255,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-connection': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../connection + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3624,6 +3627,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller @@ -3666,6 +3672,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path From 6e4087626dff1fefd5816d0f5a5fbc65b5716240 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:49:33 +0800 Subject: [PATCH 08/18] refactor(apiproxy)!: remove directory-picker RPCs --- .../tests/fake-api.client.ts | 17 --- packages/client/connection/src/client/api.ts | 1 - .../client/connection/src/client/fixture.ts | 39 ----- .../client/connection/src/client/index.ts | 1 - .../connection/tests/fake-api.client.ts | 17 --- .../connection/tests/node-half.host.spec.ts | 4 +- packages/host/apiproxy/src/api-proxy.ts | 75 ---------- packages/host/apiproxy/src/api/host.schema.ts | 44 ------ packages/host/apiproxy/src/api/host.ts | 58 -------- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 3 - packages/host/apiproxy/src/api/rpc.schema.ts | 4 - packages/host/apiproxy/src/api/rpc.ts | 4 - packages/host/apiproxy/src/fetch/client.ts | 37 +---- packages/host/apiproxy/src/fetch/handler.ts | 7 +- packages/host/apiproxy/src/index.ts | 1 + .../apiproxy/tests/api-proxy-host.spec.ts | 137 +----------------- .../apiproxy/tests/client-handler.spec.ts | 3 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 77 +--------- .../host/apiproxy/tests/rpc-schemas.spec.ts | 31 +--- 20 files changed, 25 insertions(+), 537 deletions(-) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 1625ac65e7..98d7196358 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -167,23 +167,9 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) - onPickDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: null })) onOpenPath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) - onListDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) - - onCreateDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] private readonly workspaceConns: ValueStreamConn[] = [] @@ -210,9 +196,6 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), - pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), - listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)), - createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 3a3c78dfa5..113e72212c 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,6 @@ export type { ApiProxy, HostApi, - DirectoryEntry, DirectoryListing, ResponseValue, SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0e0eab03e5..c3bb6b503d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -3314,42 +3314,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, }), - // Deterministic native pick: the keyless lanes drive the full - // pick-then-adopt path without an OS chooser (design-mock content, - // same tree the browse primitives serve). - pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), - listDirectory: (request) => { - const target = request.payload.path ?? FIXTURE_HOME - const children = childrenOf(target) - if (children === undefined) { - return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }) - } - return ok(request, { - path: target, - home: FIXTURE_HOME, - crumbs: crumbsOf(target), - entries: [...children].sort((a, b) => a.localeCompare(b)) - .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), - // The fixture tree is tiny; no level ever reaches a backend bound. - truncated: false, - }) - }, - createDirectory: (request) => { - const parent = request.payload.path - const children = childrenOf(parent) - if (children === undefined) { - return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) - } - // Same root special case as listDirectory's entry paths: a plain join - // under '/' would mint '//name' and fork the tree's identity. - const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}` - if (children.includes(request.payload.name)) { - return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) - } - directoryTree.set(parent, [...children, request.payload.name]) - directoryTree.set(target, []) - return ok(request, { path: target }) - }, openPath: request => ok(request, { opened: true as const }), }, agentPresets: { @@ -3652,9 +3616,6 @@ export class FixtureApiClient extends AbstractApiClient { ): Promise> { switch (method) { case 'host.describe': return this.api.host.describe(request) - case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) - case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) - case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 358b2f41cc..8136254f1c 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -31,7 +31,6 @@ declare module '@deepseek-ai/cordis' { // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, HostApi, - DirectoryEntry, DirectoryListing, SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 93ab3af3b0..c4bd0d03ff 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -50,30 +50,13 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) - onPickDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: null })) onOpenPath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) - onListDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) - - onCreateDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly generationConns: StreamConn[] = [] readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), - pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), - listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)), - createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index e1cb7abe83..1cead76b09 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -174,7 +174,7 @@ describe('connection node half', () => { it('requires the same browser session for every method on every trusted authority', async () => { const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const methods = [ - 'host.pickDirectory', 'host.openPath', + 'host.openPath', 'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set', 'llm.discoverModels', 'llm.models', 'agentPreset.openDocument', ] @@ -503,7 +503,7 @@ describe('connection node half over a real HTTP server', () => { const methods = [ 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', - 'host.pickDirectory', 'host.openPath', + 'host.openPath', 'llm.discoverModels', 'agentPreset.openDocument', 'llm.providers', 'llm.models', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3c3f8210d6..fc94e58035 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,7 +41,6 @@ import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@dee import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' -import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Read live abort state across awaits without treating it as synchronously immutable. */ @@ -59,14 +58,6 @@ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } -/** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */ -function directoryError(error: unknown): RpcError { - if (error instanceof DirectoryPickerError) { - return { code: error.code, message: error.message, details: { path: error.path } } - } - return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } -} - /** Deployment metadata and Host integrations consumed by the API implementation. */ export interface ApiProxyDefaults { /** Current deployment model selection reported by `host.describe`. */ @@ -290,72 +281,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }, - async pickDirectory(request, signal) { - const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'native') { - return err(request, { - code: 'directory-picker-unavailable', - message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`, - details: { capability: capability.kind }, - }) - } - try { - const path = await capability.pick(signal) - return ok(request, { path }) - } catch (error: unknown) { - if (signal.aborted) { - return err(request, { - code: 'cancelled', - message: 'directory picker was aborted', - details: {}, - }) - } - return err(request, { - code: 'internal', - message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`, - details: {}, - }) - } - }, - - async listDirectory(request, signal) { - const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'browse') { - return err(request, { - code: 'directory-picker-unavailable', - message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, - details: { capability: capability.kind }, - }) - } - try { - // The carrier's signal follows the caller: a disconnect or timeout - // stops the backend's directory scan instead of outliving it. - return ok(request, await capability.list(request.payload.path, signal)) - } catch (error: unknown) { - // An abort is the caller's own timeout/disconnect, not a server failure. - if (signal.aborted) { - return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} }) - } - return err(request, directoryError(error)) - } - }, - - async createDirectory(request) { - const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'browse') { - return err(request, { - code: 'directory-picker-unavailable', - message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, - details: { capability: capability.kind }, - }) - } - try { - return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) }) - } catch (error: unknown) { - return err(request, directoryError(error)) - } - }, - async openPath(request, signal) { return openPath(request, request.payload.path, signal) }, diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 1a520f1e30..6735e55639 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,7 +3,6 @@ */ import { z } from 'zod' -import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' @@ -21,49 +20,6 @@ export const hostDescribeValueSchema = z.object({ canOpenPath: z.boolean(), }) satisfies z.ZodType>> -/** host.pickDirectory request payload (empty object literal). */ -export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType>> - -/** host.pickDirectory response value; null means the user cancelled. */ -export const hostPickDirectoryValueSchema = z.object({ - path: z.string().nullable(), -}) satisfies z.ZodType>> - -/** Directory row shared by listing entries and breadcrumb crumbs. */ -export const directoryEntrySchema = z.object({ - name: z.string(), - path: z.string(), - hidden: z.boolean(), -}) satisfies z.ZodType> - -/** host.listDirectory request payload; an absent path lists the home directory. */ -export const hostListDirectoryRequestSchema = z.object({ - path: z.string().optional(), -}) satisfies z.ZodType>> - -/** host.listDirectory response value. */ -export const hostListDirectoryValueSchema = z.object({ - path: z.string(), - home: z.string(), - crumbs: z.array(directoryEntrySchema), - entries: z.array(directoryEntrySchema), - truncated: z.boolean(), -}) satisfies z.ZodType>> - -/** host.createDirectory request payload: name must be one plain path segment. */ -export const hostCreateDirectoryRequestSchema = z.object({ - path: z.string(), - name: z.string(), -}).refine( - payload => payload.name.trim() !== '' && payload.name !== '.' && payload.name !== '..' - && !/[/\\]/.test(payload.name), - { message: 'host.createDirectory requires a single non-blank path segment name' }, -) satisfies z.ZodType>> - -/** host.createDirectory response value: the created directory's absolute path. */ -export const hostCreateDirectoryValueSchema = z.object({ - path: z.string(), -}) satisfies z.ZodType>> /** host.openPath request payload. */ export const hostOpenPathRequestSchema = z.object({ path: z.string().min(1), diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index e700b86db3..ceb7998d2b 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -5,33 +5,6 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' -/** One directory row of a listing: a child entry or a breadcrumb ancestor. */ -export interface DirectoryEntry { - /** Base name shown in a browser row (a root crumb carries its full path). */ - name: string - /** Absolute host path — the client never joins path segments itself. */ - path: string - /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ - hidden: boolean -} - -/** host.listDirectory response value: one directory level plus its ancestry. */ -export interface DirectoryListing { - /** Absolute path of the listed directory. */ - path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ - home: string - /** - * Ancestor chain from the filesystem root to the listed directory - * inclusive; every crumb is a jump target (crumb `hidden` is always false). - */ - crumbs: DirectoryEntry[] - /** Direct child directories, name-sorted; symlinks to directories included. */ - entries: DirectoryEntry[] - /** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */ - truncated: boolean -} - /** Host-level unary methods. */ export interface HostApi { /** @@ -54,37 +27,6 @@ export interface HostApi { canOpenPath: boolean }>> - /** - * Open the operating system's single-directory picker; cancellation returns - * null. Only served under the `native` capability. - */ - pickDirectory( - request: RpcRequest<{}>, - signal: AbortSignal, - ): Promise> - - /** - * List one directory level for the in-app browser; an absent path lists the - * host account's home directory. Only served under the `browse` capability; - * unreadable or missing targets fail with `directory-unreadable`. The - * carrier's request signal follows the caller, stopping the backend's scan - * on disconnect or timeout. - */ - listDirectory( - request: RpcRequest<{ path?: string }>, - signal: AbortSignal, - ): Promise> - - /** - * Create one child directory under an existing parent (the browser's - * "New folder"). Only served under the `browse` capability; an existing - * child fails with `directory-exists`, every other filesystem failure with - * `directory-create-failed`. - */ - createDirectory( - request: RpcRequest<{ path: string; name: string }>, - ): Promise> - /** * Open a filesystem path with the operating system's default application * (Finder / Explorer / xdg-open hand-off). The browser carrier's diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 434c362e1a..b151677137 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -29,7 +29,7 @@ export type { ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, } from '@deepseek-ai/dsh-api-session-controller/types' -export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' +export type { HostApi } from './host.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi } from './agent-presets.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index d636963e82..2590b4cec7 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -18,9 +18,6 @@ import type { RpcResponse } from './rpc.ts' */ export interface RpcMethodMap { 'host.describe': HostApi['describe'] - 'host.pickDirectory': HostApi['pickDirectory'] - 'host.listDirectory': HostApi['listDirectory'] - 'host.createDirectory': HostApi['createDirectory'] 'host.openPath': HostApi['openPath'] 'skill.list': SkillsApi['list'] 'agentPreset.openDocument': AgentPresetsApi['openDocument'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 498c7c3a1f..57ca87bcf3 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -36,10 +36,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }), - z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index b154a5e14a..7448ef669c 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -31,10 +31,6 @@ export interface RpcErrorDetailsMap { 'cancelled': {} 'session-not-found': { sessionId: SessionId } 'invalid-time-zone': { value: string } - 'directory-unreadable': { path: string } - 'directory-exists': { path: string } - 'directory-create-failed': { path: string } - 'directory-picker-unavailable': { capability: string } 'agent-preset-read-only': { agentPreset: string; reason: string } 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index f65d5fafff..4a567f8638 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -13,8 +13,7 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { serverResponseSchema } from '../api/rpc.schema.ts' import { - hostCreateDirectoryValueSchema, hostDescribeValueSchema, - hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema, + hostDescribeValueSchema, hostOpenPathValueSchema, } from '../api/host.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { @@ -44,9 +43,6 @@ import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSc export interface IApiClient { host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> - pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> - listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise>> - createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise>> openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise>> } skills: { @@ -80,9 +76,6 @@ export interface IApiClient { */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { 'host.describe': hostDescribeValueSchema, - 'host.pickDirectory': hostPickDirectoryValueSchema, - 'host.listDirectory': hostListDirectoryValueSchema, - 'host.createDirectory': hostCreateDirectoryValueSchema, 'host.openPath': hostOpenPathValueSchema, 'skill.list': skillListValueSchema, 'agentPreset.openDocument': agentPresetOpenDocumentValueSchema, @@ -102,9 +95,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType void>() - /** @param timeoutMs - timeout for bounded unary calls; user-paced calls do not use it. */ + /** @param timeoutMs - timeout for unary calls. */ constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */ @@ -178,24 +168,21 @@ export abstract class AbstractApiClient implements IApiClient { /** * Shared POST leg of unary calls: JSON body, - * optional default timeout merged with the caller's external signal, non-2xx → transport throw. + * default timeout merged with the caller's external signal, non-2xx → transport throw. */ private async postJson( path: string, body: ClientRequest, signal: AbortSignal | undefined, - timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise { - const requestSignal = timeoutPolicy === 'default' - ? signal === undefined - ? AbortSignal.timeout(this.timeoutMs) - : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) - : signal + const requestSignal = signal === undefined + ? AbortSignal.timeout(this.timeoutMs) + : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) const response = await this.doFetch(new URL(path, this.resolveBase()), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), - ...requestSignal === undefined ? {} : { signal: requestSignal }, + signal: requestSignal, }) if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`) return response @@ -210,11 +197,10 @@ export abstract class AbstractApiClient implements IApiClient { method: K, payload: RequestPayload, signal?: AbortSignal, - timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise>> { const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload } this.onEnvelope(message) - const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy) + const response = await this.postJson(`/api/${method}`, message, signal) const full = serverResponseSchema.parse(await response.json()) this.onEnvelope(full) if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`) @@ -229,13 +215,6 @@ export abstract class AbstractApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload, signal) => this.callUnary('host.describe', payload, signal), - // A native system dialog is user-paced and may legitimately stay open - // longer than the normal unary deadline. Caller/connection aborts remain. - pickDirectory: (payload, signal) => this.callUnary( - 'host.pickDirectory', payload, signal, 'caller-signal-only', - ), - listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal), - createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal), openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 6b3a7d3b13..d4f3ea58ed 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -15,9 +15,7 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { clientRequestSchema } from '../api/rpc.schema.ts' import { - hostCreateDirectoryRequestSchema, hostDescribeRequestSchema, - hostListDirectoryRequestSchema, hostOpenPathRequestSchema, - hostPickDirectoryRequestSchema, + hostDescribeRequestSchema, hostOpenPathRequestSchema, } from '../api/host.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { @@ -50,9 +48,6 @@ type UnaryRoutes = { const UNARY_ROUTES: UnaryRoutes = { 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, - 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, - 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, - 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index aab8e952d2..98a0e091c8 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -15,6 +15,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-host-directory-picker' import type { ApiProxy } from './api/index.ts' import { createApiProxy } from './api-proxy.ts' import { diff --git a/packages/host/apiproxy/tests/api-proxy-host.spec.ts b/packages/host/apiproxy/tests/api-proxy-host.spec.ts index 74c068150f..681f2d0ed4 100644 --- a/packages/host/apiproxy/tests/api-proxy-host.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-host.spec.ts @@ -2,8 +2,6 @@ import { homedir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' -import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '../src/api-proxy.ts' @@ -26,7 +24,6 @@ function expectOk(response: { readonly result: { readonly ok: true; readonly } async function harness( - picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise canOpenPath?: () => boolean @@ -35,7 +32,6 @@ async function harness( const ctx = new Context() contexts.push(ctx) await ctx.plugin(AgentRegistry) - ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd: '/tmp/dsh-apiproxy-host', @@ -45,135 +41,10 @@ async function harness( return { api } } -describe('host.pickDirectory', () => { - it('returns a selected path or explicit cancellation from the native capability', async () => { - const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' }) - expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) - .toEqual({ ok: true, value: { path: '/tmp/project' } }) - - const cancelled = await harness({ kind: 'native', pick: async () => null }) - expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) - .toEqual({ ok: true, value: { path: null } }) - }) - - it('propagates abort into the native capability as a cancelled RPC error', async () => { - const { api } = await harness({ - kind: 'native', - pick: signal => new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) - }), - }) - const abort = new AbortController() - const pending = api.host.pickDirectory(request({}), abort.signal) - abort.abort() - expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) - }) - - it('folds a non-abort native-chooser failure into an internal error', async () => { - const { api } = await harness({ - kind: 'native', - pick: async () => { throw new Error('no chooser installed') }, - }) - const response = await api.host.pickDirectory(request({}), new AbortController().signal) - expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) - }) - - it('refuses the native RPC under a browse composition', async () => { - const { api } = await harness(BROWSE_STUB) - const response = await api.host.pickDirectory(request({}), new AbortController().signal) - expect(response.result).toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } }, - }) - }) -}) - -const BROWSE_STUB: DirectoryPickerCapability = { - kind: 'browse', - list: async (path) => { - if (path === '/denied') { - throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') - } - const target = path ?? '/home/user' - return { - path: target, - home: '/home/user', - crumbs: [{ name: '/', path: '/', hidden: false }], - entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], - truncated: false, - } - }, - createDirectory: async (path, name) => { - if (name === 'taken') { - throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') - } - if (name === 'unwritable') throw new Error('disk detached') - return `${path}/${name}` - }, -} - -describe('host.listDirectory / host.createDirectory', () => { - it('serves listings and creation through the browse capability, defaulting to home', async () => { - const { api } = await harness(BROWSE_STUB) - const home = await api.host.listDirectory(request({}), new AbortController().signal) - expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) - const listed = await api.host.listDirectory( - request({ path: '/home/user/projects' }), - new AbortController().signal, - ) - expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) - const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) - expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) - }) - - it('maps typed picker failures onto wire errors and folds unknown throws to internal', async () => { - const { api } = await harness(BROWSE_STUB) - expect((await api.host.listDirectory( - request({ path: '/denied' }), - new AbortController().signal, - )).result).toMatchObject({ - ok: false, - error: { code: 'directory-unreadable', details: { path: '/denied' } }, - }) - expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result) - .toMatchObject({ ok: false, error: { code: 'directory-exists' } }) - expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result) - .toMatchObject({ ok: false, error: { code: 'internal' } }) - }) - - it('reports an aborted listing as cancelled', async () => { - const { api } = await harness({ - kind: 'browse', - list: (_path, signal) => new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) - }), - createDirectory: async () => '/never', - }) - const abort = new AbortController() - const pending = api.host.listDirectory(request({}), abort.signal) - abort.abort() - expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) - }) - - it('refuses the browse RPCs under a native composition', async () => { - const { api } = await harness() - expect((await api.host.listDirectory(request({}), new AbortController().signal)).result) - .toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, - }) - expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result) - .toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, - }) - }) -}) - describe('host.openPath', () => { it('describes whether the deployment can reach a native desktop', async () => { - const visible = await harness(undefined, { canOpenPath: () => true }) - const headless = await harness(undefined, { canOpenPath: () => false }) + const visible = await harness({ canOpenPath: () => true }) + const headless = await harness({ canOpenPath: () => false }) expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) @@ -181,7 +52,7 @@ describe('host.openPath', () => { it('opens through the injected native boundary', async () => { const opened: string[] = [] - const { api } = await harness(undefined, { + const { api } = await harness({ openPath: async (path) => { opened.push(path) }, }) expect((await api.host.openPath( @@ -192,7 +63,7 @@ describe('host.openPath', () => { }) it('propagates abort into the native boundary as a cancelled RPC error', async () => { - const { api } = await harness(undefined, { + const { api } = await harness({ openPath: (_path, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) }), diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index d6be6bf619..45b6d153ce 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -29,9 +29,6 @@ function scriptedApi(overrides: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, }), - pickDirectory: r => ok(r, { path: null }), - listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }), - createDirectory: r => ok(r, { path: '/t/new' }), openPath: r => ok(r, { opened: true as const }), ...overrides.host, }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index b56fa89d82..9bbd19640b 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -18,15 +18,6 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { }, } }, - async pickDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } - }, - async listDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } } - }, - async createDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } } - }, async openPath(request) { return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } }, @@ -124,29 +115,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { .toEqual({ ok: true, value: { opened: true } }) }) - it('round-trips the native picker without the default unary timeout', async () => { - const api = fakeApi() - api.host.pickDirectory = async (request) => { - await new Promise(resolve => setTimeout(resolve, 15)) - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/project' } } } - } - const response = await client(api, 1).host.pickDirectory({}) - expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } }) - }) - - it('round-trips the browse listing and creation calls through the wire form', async () => { - const c = client() - const listed = await c.host.listDirectory({ path: '/w' }) - expect(listed.result).toEqual({ - ok: true, - value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }, - }) - const home = await c.host.listDirectory({}) - expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } }) - const created = await c.host.createDirectory({ path: '/w', name: 'fresh' }) - expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } }) - }) - it('round-trips host.openPath through the wire form', async () => { const api = fakeApi() let opened: string | undefined @@ -165,41 +133,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) }) - it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => { - vi.useFakeTimers() - const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { - const controller = new AbortController() - setTimeout(() => { - controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError')) - }, milliseconds) - return controller.signal - }) - try { - const api = fakeApi() - api.host.pickDirectory = async (request) => { - await new Promise(resolve => setTimeout(resolve, 30_001)) - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } } - } - const execution = client(api).host.pickDirectory({}) - const assertion = expect(execution).resolves.toMatchObject({ - result: { ok: true, value: { path: '/tmp/slow' } }, - }) - - await Promise.all([ - vi.advanceTimersByTimeAsync(30_001), - assertion, - ]) - expect(timeoutSpy).not.toHaveBeenCalled() - } finally { - timeoutSpy.mockRestore() - vi.useRealTimers() - } - }) - - it('keeps caller and connection aborts on a deadline-exempt unary', async () => { + it('keeps caller and connection aborts on a signal-taking unary', async () => { const api = fakeApi() const started = Promise.withResolvers() - api.host.pickDirectory = async (request, signal) => { + api.host.openPath = async (request, signal) => { started.resolve(signal) if (!signal.aborted) { await new Promise((resolve) => { @@ -212,7 +149,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } } const controller = new AbortController() - const execution = client(api).host.pickDirectory({}, controller.signal) + const execution = client(api).host.openPath({ path: '/tmp/a.txt' }, controller.signal) const handlerSignal = await started.promise controller.abort(new Error('connection closed')) @@ -221,9 +158,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(handlerSignal.aborted).toBe(true) }) - it('propagates the carrier Request signal into host.pickDirectory', async () => { + it('propagates the carrier Request signal into host.openPath', async () => { const api = fakeApi() - api.host.pickDirectory = async (request, signal) => { + api.host.openPath = async (request, signal) => { if (!signal.aborted) { await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) @@ -236,8 +173,8 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } const handler = toFetchHandler(api) const controller = new AbortController() - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} }) - const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', { + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-opener', method: 'host.openPath', payload: { path: '/tmp/a.txt' } }) + const pending = handler.fetch(new Request('http://x/api/host.openPath', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal, })) controller.abort() diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 0a803154b8..429e31361f 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -5,11 +5,7 @@ import { rpcResultSchema, serverResponseSchema, } from '../src/api/rpc.schema.ts' import { z } from 'zod' -import { - hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema, - hostDescribeRequestSchema, hostDescribeValueSchema, - hostListDirectoryRequestSchema, hostListDirectoryValueSchema, -} from '../src/api/host.schema.ts' +import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetOpenDocumentValueSchema } from '../src/api/agent-presets.schema.ts' @@ -36,10 +32,6 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled') expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone') - expect(rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: { path: '/x' } }).code).toBe('directory-unreadable') - expect(rpcErrorSchema.parse({ code: 'directory-exists', message: 'm', details: { path: '/x' } }).code).toBe('directory-exists') - expect(rpcErrorSchema.parse({ code: 'directory-create-failed', message: 'm', details: { path: '/x' } }).code).toBe('directory-create-failed') - expect(rpcErrorSchema.parse({ code: 'directory-picker-unavailable', message: 'm', details: { capability: 'none' } }).code).toBe('directory-picker-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only') expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked') expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') @@ -55,7 +47,6 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) @@ -109,26 +100,6 @@ describe('host domain schemas', () => { version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true, })).toThrow() }) - - it('validates the browse listing/creation payloads', () => { - expect(hostListDirectoryRequestSchema.parse({})).toEqual({}) - expect(hostListDirectoryRequestSchema.parse({ path: '/x' })).toEqual({ path: '/x' }) - const listing = hostListDirectoryValueSchema.parse({ - path: '/home/u/p', - home: '/home/u', - crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }], - entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }], - truncated: false, - }) - expect(listing.entries[0]?.hidden).toBe(true) - // The flag is part of the wire value, not an optional decoration. - expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow() - expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' }) - for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { - expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow() - } - expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' }) - }) }) describe('skills domain schemas', () => { From 54d77eff00e7314210146f0d25e2735a963736a3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:50:46 +0800 Subject: [PATCH 09/18] docs(directory-picker): record the Remote transport --- docs/capability-seams.i18n.yaml | 4 +-- docs/capability-seams.md | 7 ++-- docs/capability-seams.zh.md | 7 ++-- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/module-graph.i18n.yaml | 4 +-- docs/module-graph.md | 14 +++++--- docs/module-graph.zh.md | 14 +++++--- docs/subsystems/workspace.i18n.yaml | 4 +-- docs/subsystems/workspace.md | 34 ++++++++++++++++++ docs/subsystems/workspace.zh.md | 34 ++++++++++++++++++ .../api/workspace-controller/README.i18n.yaml | 4 +-- packages/api/workspace-controller/README.md | 2 +- .../api/workspace-controller/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 35 ++++++++++++++++++- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 3 +- packages/host/apiproxy/README.zh.md | 3 +- .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../directory-picker-native/README.i18n.yaml | 4 +-- .../host/directory-picker-native/README.md | 2 +- .../host/directory-picker-native/README.zh.md | 2 +- .../host/directory-picker/README.i18n.yaml | 4 +-- packages/host/directory-picker/README.md | 4 +-- packages/host/directory-picker/README.zh.md | 4 +-- scripts/gen-cordis-catalog.ts | 2 ++ scripts/gen-doc-graphs.ts | 9 ++++- 29 files changed, 170 insertions(+), 48 deletions(-) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 683f690e37..9d54d834a9 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 2ee2d1b989c4eb25b245cfd528b2fa1ba3bb0272 -capability-seams.zh.md: 7ecda568ef4df4d360414ee872d2b0241ebe1dce +capability-seams.md: 7cf93600cbf0d2deef586135f299058629732685 +capability-seams.zh.md: 3d79717d811fc3cd4e51c19c34c02a4e51d5da21 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2ee2d1b989..7cf93600cb 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,6 +40,7 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -217,6 +218,7 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments @@ -348,7 +350,7 @@ flowchart LR svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek - svc_directoryPicker --> pkg_host_apiproxy + svc_directoryPicker --> pkg_api_workspace_controller svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b @@ -459,6 +461,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | +| `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | @@ -506,7 +509,7 @@ flowchart LR | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | -| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`host-apiproxy`](../packages/host/apiproxy) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | +| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7ecda568ef..3d79717d81 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -42,6 +42,7 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -219,6 +220,7 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments @@ -350,7 +352,7 @@ flowchart LR svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek - svc_directoryPicker --> pkg_host_apiproxy + svc_directoryPicker --> pkg_api_workspace_controller svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b @@ -461,6 +463,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | +| `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | @@ -508,7 +511,7 @@ flowchart LR | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | -| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`host-apiproxy`](../packages/host/apiproxy) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | +| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | 通过增量 `dsh.client` 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a8fb510281..2004935a78 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: f91a64d73af53cc9c57d9d2e795f8f54bcf9eacf +config-catalog.md: 6e001376cfba7c04a07acd56ac733f089283f0bf config-catalog.zh.md: 8b1ab084668ef8e1568801855f9aebc19e76a5e3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f91a64d73a..6e001376cf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -796,7 +796,7 @@ export interface Config { } ``` -Source: [`packages/host/apiproxy/src/index.ts:41`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:42`](../packages/host/apiproxy/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 5e4627d0d4..afb151522f 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e28dc23d3baab10a5bffc6014339a6796daa845d -module-graph.zh.md: e32626810d7208cf534a164558dc0fec0dc3bf28 +module-graph.md: 1668d509cea592acc6a6bfd15bb4ba0e5d55d5c8 +module-graph.zh.md: 03edacc1abfe4bfdc0037dd4712e0ce67a014103 diff --git a/docs/module-graph.md b/docs/module-graph.md index e28dc23d3b..1668d509ce 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1083,6 +1083,7 @@ flowchart TD pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm @@ -1256,6 +1257,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain @@ -1388,6 +1390,7 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_session pkg_client_ui_sidebar --> pkg_client_ui_workspace pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_remotes pkg_client_ui_workspace --> pkg_api_session_controller pkg_client_ui_workspace --> pkg_api_workspace_controller pkg_client_ui_workspace --> pkg_client_connection @@ -1398,6 +1401,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_sidebar pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session + pkg_client_ui_workspace --> pkg_typert_protocol pkg_client_ui_workspace --> pkg_util_workspace_path pkg_client_ui_agent_preset --> pkg_agent_presets pkg_client_ui_agent_preset --> pkg_api_remotes @@ -1425,7 +1429,7 @@ flowchart TD pkg_client_ui_brand_official --> pkg_client_ui_renderer pkg_client_ui_brand_official --> pkg_client_ui_sidebar pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_api_remotes pkg_client_ui_directory_picker_browse --> pkg_client_locale pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace @@ -1854,7 +1858,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1877,7 +1881,7 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1894,11 +1898,11 @@ flowchart TD | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e32626810d..03edacc1ab 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1085,6 +1085,7 @@ flowchart TD pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm @@ -1258,6 +1259,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain @@ -1390,6 +1392,7 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_session pkg_client_ui_sidebar --> pkg_client_ui_workspace pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_remotes pkg_client_ui_workspace --> pkg_api_session_controller pkg_client_ui_workspace --> pkg_api_workspace_controller pkg_client_ui_workspace --> pkg_client_connection @@ -1400,6 +1403,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_sidebar pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session + pkg_client_ui_workspace --> pkg_typert_protocol pkg_client_ui_workspace --> pkg_util_workspace_path pkg_client_ui_agent_preset --> pkg_agent_presets pkg_client_ui_agent_preset --> pkg_api_remotes @@ -1427,7 +1431,7 @@ flowchart TD pkg_client_ui_brand_official --> pkg_client_ui_renderer pkg_client_ui_brand_official --> pkg_client_ui_sidebar pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_api_remotes pkg_client_ui_directory_picker_browse --> pkg_client_locale pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace @@ -1856,7 +1860,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1879,7 +1883,7 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1896,11 +1900,11 @@ flowchart TD | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index a41ab20b51..074fcdd610 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: af2a8d4c5abbde04764e26751ba178fa99b2b3ef -workspace.zh.md: f54444f052c8faffc7670468d5423123cd3aeeac +workspace.md: 82bbe310d490653bae5d88f41d1fcbe76833b431 +workspace.zh.md: 9bf63b7a90f9136173fbec16b55a22821fa380e5 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index af2a8d4c5a..82bbe310d4 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -149,6 +149,40 @@ abstract capability(): DirectoryPickerCapability Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) + + +### `ctx.directoryPickerController` — `DirectoryPickerController` + +Host service backing the generated `ctx.remote.directoryPicker` namespace. The seam it exports is abstract and therefore never a Loader entry of its own, so this controller carries the wire verbs: one composed backend serves either the native chooser or the browse primitives, and a verb the composition cannot serve is refused rather than approximated. + +```ts cordis-catalog +/** + * Open the host's OS chooser for a Remote caller. + * @param signal - caller lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ +@Remote('pick') async pick(signal: AbortSignal): Promise + +/** + * List one directory level for a Remote caller's in-app browser. + * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the backend's scan instead of + * letting it outlive a disconnected caller. + * @returns the level's listing with its ancestry. + */ +@Remote('list') async list(path: string | undefined, signal: AbortSignal): Promise + +/** + * Create one child directory for a Remote caller's in-app browser. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ +@Remote('createDirectory') async createDirectory(path: string, name: string): Promise +``` + +Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index f54444f052..9bf63b7a90 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -149,6 +149,40 @@ abstract capability(): DirectoryPickerCapability Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) + + +### `ctx.directoryPickerController` — `DirectoryPickerController` + +Host service backing the generated `ctx.remote.directoryPicker` namespace. The seam it exports is abstract and therefore never a Loader entry of its own, so this controller carries the wire verbs: one composed backend serves either the native chooser or the browse primitives, and a verb the composition cannot serve is refused rather than approximated. + +```ts cordis-catalog +/** + * Open the host's OS chooser for a Remote caller. + * @param signal - caller lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ +@Remote('pick') async pick(signal: AbortSignal): Promise + +/** + * List one directory level for a Remote caller's in-app browser. + * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the backend's scan instead of + * letting it outlive a disconnected caller. + * @returns the level's listing with its ancestry. + */ +@Remote('list') async list(path: string | undefined, signal: AbortSignal): Promise + +/** + * Create one child directory for a Remote caller's in-app browser. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ +@Remote('createDirectory') async createDirectory(path: string, name: string): Promise +``` + +Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index eee1d5bd5a..3f837582cb 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md -README.md: d2f89e6c9f0118be9150650c1c6dec859375f990 -README.zh.md: 3411ca0642df7fa7f8652d1863905868f54cf4bd +README.md: 731a6331e2a19991921c022759f6bbd971cef525 +README.zh.md: f46c78b7b300f26983eee94d3dfdb488d236116e diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index d2f89e6c9f..731a6331e2 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive Sessions from Workspace navigation, and follow the complete Workspace projection. Use it through API Gateway when a Client must change or follow Workspace navigation. +`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive Sessions from Workspace navigation, and follow the complete Workspace projection. Use it through API Gateway when a Client must change or follow Workspace navigation. The package also owns `ctx.directoryPickerController` and the generated `ctx.remote.directoryPicker` namespace, because the directory-picking seam it carries is abstract and never a Loader entry of its own. ## Table of Contents diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index 3411ca0642..f46c78b7b3 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,从 Workspace 导航中归档 Session,以及跟随完整的 Workspace 投影。当 Client 必须修改或跟随 Workspace 导航时,请通过 API Gateway 使用它。 +`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,从 Workspace 导航中归档 Session,以及跟随完整的 Workspace 投影。当 Client 必须修改或跟随 Workspace 导航时,请通过 API Gateway 使用它。本包同时拥有 `ctx.directoryPickerController` 与生成的 `ctx.remote.directoryPicker` namespace,因为它承载的选目录 seam 是抽象的,自身从不作为 Loader entry。 ## 目录 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4f5da0ccc8..e90a33af0b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -768,6 +768,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'directoryPickerController', + summary: 'Host service backing the generated `ctx.remote.directoryPicker` namespace.', + description: 'Host service backing the generated `ctx.remote.directoryPicker` namespace. The seam it exports is abstract and therefore never a Loader entry of its own, so this controller carries the wire verbs: one composed backend serves either the native chooser or the browse primitives, and a verb the composition cannot serve is refused rather than approximated.', + methods: [ + { + signature: '@Remote(\'pick\') async pick(signal: AbortSignal): Promise', + description: 'Open the host\'s OS chooser for a Remote caller.', + parameters: [{ name: 'signal', description: 'caller lifetime; abort terminates the chooser.' }], + returns: 'the chosen absolute path, or null when the operator cancels.', + }, + { + signature: '@Remote(\'list\') async list(path: string | undefined, signal: AbortSignal): Promise', + description: 'List one directory level for a Remote caller\'s in-app browser.', + parameters: [{ name: 'path', description: 'absolute directory to list; absent lists the home directory.' }, { name: 'signal', description: 'caller lifetime; abort stops the backend\'s scan instead of letting it outlive a disconnected caller.' }], + returns: 'the level\'s listing with its ancestry.', + }, + { + signature: '@Remote(\'createDirectory\') async createDirectory(path: string, name: string): Promise', + description: 'Create one child directory for a Remote caller\'s in-app browser.', + parameters: [{ name: 'path', description: 'absolute existing parent directory.' }, { name: 'name', description: 'single non-blank path segment.' }], + returns: 'the created directory\'s absolute path.', + }, + ], + }, { key: 'e2b', summary: 'Creates one lazily consumable E2B SDK handle and deletes the sandbox at timeout or disposal.', @@ -3660,6 +3685,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DiffResultView', declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}', }, + { + name: 'DirectoryEntry', + declaration: 'export interface DirectoryEntry {\n name: string;\n path: string;\n hidden: boolean;\n}', + }, + { + name: 'DirectoryListing', + declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n truncated: boolean;\n}', + }, { name: 'DirectoryPickerBrowseCapability', declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string, signal?: AbortSignal): Promise;\n createDirectory(path: string, name: string): Promise;\n}', @@ -4422,7 +4455,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RpcErrorDetailsMap', - declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', + declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', }, { name: 'RpcId', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 209e601d83..db9c6a286b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 3460d0ec021f658fe91e1ed8b617196d437cb937 -README.zh.md: a6cfac365fe728f26b3afd22ef32e661bd0146ff +README.md: 9f96999faf26090ccb58763a9301381a4d21d2de +README.zh.md: 3633880a27e23c2c35706d97b8c905f24a17882d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3460d0ec02..9f96999faf 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -40,7 +40,7 @@ The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross ### What the gateway exposes -The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, pickDirectory, listDirectory, createDirectory, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. +The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. ### Sessions and history @@ -140,7 +140,6 @@ These limits define where the gateway is a poor fit; they are current package co - **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `job.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Search failures include provider diagnostics** — the gateway is a single-user local service; a carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. -- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). - **Cold-list hints degrade only toward visibility and older ordering** — a projection-cache miss or stale `lastPromptAt` falls back to `createdAt` unless an eligible small artifact supplies an exact fold. The [bounded blank-verification decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) owns this safety direction; an authoritative exact recency index remains scoped in the [last-activity-index proposal](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index a6cfac365f..3633880a27 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -40,7 +40,7 @@ HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站 ### 网关暴露什么 -API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、pickDirectory、listDirectory、createDirectory、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 +API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 ### 会话与历史 @@ -140,7 +140,6 @@ API 按领域分组:`sessions`(list、create、history、prompt、cancel、q - **预留 seam 不进入 `RpcMethodMap`**——`prompt.mode: 'inject'`、`job.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **搜索失败会包含提供方诊断信息**——网关是单用户本地服务;将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 -- **Linux 原生选择器依赖桌面工具**——在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是浏览后端(见 [native 后端 README](../directory-picker-native/README.zh.md))。 - **冷列表提示只向“保持可见、排序偏旧”降级**——projection cache miss 或陈旧的 `lastPromptAt` 会回退到 `createdAt`,除非符合资格的小工件提供精确折叠。[有界空白验证决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)规定了这个安全方向;权威且精确的最近时间索引仍属于[最后活动索引提案](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md)的范围。 diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index a101e5580b..a9184c9313 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 0b161afa4b70c291d20523507b62789c4856d29a -README.zh.md: 4207a1eccdb913b322733f9c1e56f79d298c16b7 +README.md: 986504a48da5b1ea28c174168eec41e40bb77e4e +README.zh.md: 54a722bdad2d88e6cadc6f39c74f479851197351 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 0b161afa4b..986504a48d 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -25,7 +25,7 @@ Users who cannot reach an OS chooser still pick a workspace directory through `d ## Use this package -Compose this backend when a workspace directory must be chosen without an OS chooser — remote browsers, SSH-forwarded sessions, or unattended hosts. The workspace flow drives `host.listDirectory` and `host.createDirectory`; both primitives answer from the host filesystem. +Compose this backend when a workspace directory must be chosen without an OS chooser — remote browsers, SSH-forwarded sessions, or unattended hosts. The workspace flow drives `directoryPicker/list` and `directoryPicker/createDirectory`; both primitives answer from the host filesystem. ### Listing a directory diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 4207a1eccd..54a722bdad 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当工作区目录必须在没有 OS 选择器的情况下被选中时——远程浏览器、SSH 转发会话或无人值守宿主——组合此后端。工作区流程驱动 `host.listDirectory` 与 `host.createDirectory`;两个原语都从宿主文件系统作答。 +当工作区目录必须在没有 OS 选择器的情况下被选中时——远程浏览器、SSH 转发会话或无人值守宿主——组合此后端。工作区流程驱动 `directoryPicker/list` 与 `directoryPicker/createDirectory`;两个原语都从宿主文件系统作答。 ### 列举目录 diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index ef85adaeeb..aa59d82312 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md -README.md: 9bea790348293398b800271cfee2dce75d02072a -README.zh.md: 53c582c33a55397c1e0fe526e5b92ebc270fe8f7 +README.md: 420ec71899366733c9be538ca5900644e81b668d +README.zh.md: a6671d2f93fbc605bfa74d73e80d835cb0c7824e diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 9bea790348..420ec71899 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -33,7 +33,7 @@ Choose this backend for a workstation-local operator on macOS, Windows, or deskt ### What an operator experiences -Each call opens one native chooser on the host display and waits for the operator; aborting the caller's signal terminates the chooser process instead of leaving it open. On Linux the chooser needs either Zenity or KDialog installed; with neither present, `pick` rejects with an actionable error instead of falling back to a typed-path prompt. The browser half of this package registers a renderless flow occupant into the workspace flow — every `open` request drives `host.pickDirectory` and reports the one outcome (picked path, cancel, or failure). +Each call opens one native chooser on the host display and waits for the operator; aborting the caller's signal terminates the chooser process instead of leaving it open. On Linux the chooser needs either Zenity or KDialog installed; with neither present, `pick` rejects with an actionable error instead of falling back to a typed-path prompt. The browser half of this package registers a renderless flow occupant into the workspace flow — every `open` request drives `directoryPicker/pick` and reports the one outcome (picked path, cancel, or failure). ### Observable failures diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 53c582c33a..a6671d2f93 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 操作者会看到什么 -每次调用在宿主屏幕上打开一个原生选择器并等待操作者;中止调用方的信号会终止选择器进程,而不是让它留在屏幕上。Linux 上选择器需要安装 Zenity 或 KDialog 之一;两者都没有时,`pick` 以包含解决建议的错误拒绝,而不会回退为手输路径提示。本包的 browser 半侧向工作区流程注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并上报唯一结果(所选路径、取消或失败)。 +每次调用在宿主屏幕上打开一个原生选择器并等待操作者;中止调用方的信号会终止选择器进程,而不是让它留在屏幕上。Linux 上选择器需要安装 Zenity 或 KDialog 之一;两者都没有时,`pick` 以包含解决建议的错误拒绝,而不会回退为手输路径提示。本包的 browser 半侧向工作区流程注册一个无渲染的流程占用者——每次 `open` 请求驱动 `directoryPicker/pick`,并上报唯一结果(所选路径、取消或失败)。 ### 可观察的失败 diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 7d6d8ef665..d2d9647738 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md -README.md: 93afde4ec012fe366f0a44e8d9632596876f6b69 -README.zh.md: 1c9942cdc4271b7201d7086868d01da23ccd4d46 +README.md: bcd2c4dbfa2848e669edbc9f0ebba93804df622c +README.zh.md: 8604b54c99e88cedfedf820e3dd0f8800f9c5972 diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 93afde4ec0..bcd2c4dbfa 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -29,11 +29,11 @@ Mount exactly one directory-picker backend and let the workspace flow drive it: ### Choosing a backend -The [native backend](../directory-picker-native/README.md) is the right choice when the operator sits at the host's display: `host.pickDirectory` opens one OS chooser and returns the chosen absolute path, or `null` on cancel. The [browse backend](../directory-picker-browse/README.md) works everywhere — it lists one directory level and creates child directories from the browser, so remote clients that cannot reach an OS dialog still pick a workspace. When the host situation varies between boots, compose the [adaptive chooser](../directory-picker-auto/README.md), which resolves the situation once at boot and mounts the matching backend. +The [native backend](../directory-picker-native/README.md) is the right choice when the operator sits at the host's display: `directoryPicker/pick` opens one OS chooser and returns the chosen absolute path, or `null` on cancel. The [browse backend](../directory-picker-browse/README.md) works everywhere — it lists one directory level and creates child directories from the browser, so remote clients that cannot reach an OS dialog still pick a workspace. When the host situation varies between boots, compose the [adaptive chooser](../directory-picker-auto/README.md), which resolves the situation once at boot and mounts the matching backend. ### The capability contract -`capability()` returns a discriminated union describing how an operator selects a directory: `{ kind: 'native', pick(signal) }` for the OS chooser, or `{ kind: 'browse', list(path?), createDirectory(path, name) }` for the in-app browser. Consumers switch on `kind`; a capability kind no composition implements means the UI hides the picking affordance rather than failing. Browse failures throw the typed `DirectoryPickerError` with a closed code set — `directory-unreadable`, `directory-exists`, or `directory-create-failed` — each carrying the subject path, which the consuming gateway maps onto wire error codes. +`capability()` returns a discriminated union describing how an operator selects a directory: `{ kind: 'native', pick(signal) }` for the OS chooser, or `{ kind: 'browse', list(path?), createDirectory(path, name) }` for the in-app browser. Consumers switch on `kind`; a capability kind no composition implements means the UI hides the picking affordance rather than failing. Browse failures throw the typed `DirectoryPickerError` with a closed code set — `directory-unreadable`, `directory-exists`, or `directory-create-failed` — each carrying the subject path, which the picking Remote controller maps onto wire failure codes. ### What rows carry diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 1c9942cdc4..8604b54c99 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -29,11 +29,11 @@ web GUI 宿主通过一份约定让操作者选择工作区目录:一个只提 ### 选择后端 -当操作者坐在宿主屏幕前时,[原生后端](../directory-picker-native/README.zh.md)是正确选择:`host.pickDirectory` 打开一个 OS 选择器,返回所选绝对路径,取消时返回 `null`。[浏览后端](../directory-picker-browse/README.zh.md)处处可用——它在浏览器中列举一个目录层级并创建子目录,因此无法触达 OS 对话框的远程客户端依然能选择工作区。当宿主处境在两次启动之间变化时,组合[自适应选择器](../directory-picker-auto/README.zh.md),它在启动时判定一次处境并挂载匹配的后端。 +当操作者坐在宿主屏幕前时,[原生后端](../directory-picker-native/README.zh.md)是正确选择:`directoryPicker/pick` 打开一个 OS 选择器,返回所选绝对路径,取消时返回 `null`。[浏览后端](../directory-picker-browse/README.zh.md)处处可用——它在浏览器中列举一个目录层级并创建子目录,因此无法触达 OS 对话框的远程客户端依然能选择工作区。当宿主处境在两次启动之间变化时,组合[自适应选择器](../directory-picker-auto/README.zh.md),它在启动时判定一次处境并挂载匹配的后端。 ### 能力约定 -`capability()` 返回一个可辨识联合类型,说明操作者如何选择目录:OS 选择器为 `{ kind: 'native', pick(signal) }`,应用内浏览器为 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。消费方按 `kind` 分支;某个组合没有实现的能力类型意味着界面隐藏选择入口,而不是失败。浏览失败抛出带类型的 `DirectoryPickerError`,其错误码集合是封闭的——`directory-unreadable`、`directory-exists` 或 `directory-create-failed`——每个都携带出错对象的路径,消费网关将其 1:1 映射为协议错误码。 +`capability()` 返回一个可辨识联合类型,说明操作者如何选择目录:OS 选择器为 `{ kind: 'native', pick(signal) }`,应用内浏览器为 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。消费方按 `kind` 分支;某个组合没有实现的能力类型意味着界面隐藏选择入口,而不是失败。浏览失败抛出带类型的 `DirectoryPickerError`,其错误码集合是封闭的——`directory-unreadable`、`directory-exists` 或 `directory-create-failed`——每个都携带出错对象的路径,选目录 Remote controller 将其 1:1 映射为协议错误码。 ### 行携带什么 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 00359ad450..3d789bb894 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -118,6 +118,7 @@ export const SERVICE_PAGE: Record = { webhookRuntime: 'webhook.md', workspaceRegistry: 'workspace.md', workspaceController: 'workspace.md', + directoryPickerController: 'workspace.md', } /** @@ -595,6 +596,7 @@ export const LINK_MAP: Readonly> = { ProjectionSnapshot: 'session-projection.md', ProjectionCheckpoint: 'session-projection.md', DirectoryPickerCapability: 'workspace.md', + DirectoryListing: 'workspace.md', TypertContribution: 'invariants.md', TypertRemoteEventSource: 'typert.md', TypertFace: 'invariants.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 9e0dd0c8bf..22ded5918c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -164,6 +164,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.', }, + { + key: 'directoryPickerController', + pkg: 'api-workspace-controller', + title: 'Host directory-picking Remote controller', + mode: 'core', + note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.', + }, { key: 'invariants', pkg: 'invariants', @@ -561,7 +568,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Workspace-directory picking seam', mode: 'seam', implementations: ['host-directory-picker-native', 'host-directory-picker-browse'], - consumers: ['host-apiproxy'], + consumers: ['api-workspace-controller'], note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement).', }, { From 0a9a9ee6860de67ac1b5699c64f313a2480e5a15 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:00:25 +0800 Subject: [PATCH 10/18] docs(api): record settings Remote migration --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- ...-unary-apiproxy-remote-migration.i18n.yaml | 4 +- ...6-08-10-unary-apiproxy-remote-migration.md | 7 ++- ...8-10-unary-apiproxy-remote-migration.zh.md | 7 ++- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 7 +++ docs/capability-seams.zh.md | 7 +++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 8 +-- docs/event-producer-consumer.zh.md | 8 +-- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 36 +++++++----- docs/module-graph.zh.md | 36 +++++++----- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 46 ++++++++++++++- docs/subsystems/credentials.zh.md | 46 ++++++++++++++- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 50 ++++++++++++++++ docs/subsystems/settings.zh.md | 50 ++++++++++++++++ packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 3 +- packages/api/README.zh.md | 5 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 +- packages/api/remotes/README.zh.md | 2 +- .../api/settings-controller/README.i18n.yaml | 6 ++ packages/api/settings-controller/README.md | 57 +++++++++++++++++++ packages/api/settings-controller/README.zh.md | 57 +++++++++++++++++++ .../credentials/credentials/README.i18n.yaml | 4 +- packages/credentials/credentials/README.md | 6 +- packages/credentials/credentials/README.zh.md | 6 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 8 +-- packages/host/apiproxy/README.zh.md | 8 +-- 39 files changed, 429 insertions(+), 93 deletions(-) create mode 100644 packages/api/settings-controller/README.i18n.yaml create mode 100644 packages/api/settings-controller/README.md create mode 100644 packages/api/settings-controller/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index a345f69828..6e02fa3915 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: d5bd9c05e8352536c5c6f8b265db7dbd56a4fb84 -2026-07-30-web-config-plane.zh.md: 3c7f801766b1a6c197cc208a3c4a030b8aaac771 +2026-07-30-web-config-plane.md: c817071ed17554d06249aa6893ed759bea5d72d0 +2026-07-30-web-config-plane.zh.md: 0b15e329340051d0f63e8a5b1f8c70a29a2138d2 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index d5bd9c05e8..c817071ed1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -12,7 +12,7 @@ The request-level configuration seam made LLM adapter configuration restart-free ## Decision -**Wire domains on the compiled RPC map, rejections as codes, owner events forwarded verbatim.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` join `RpcMethodMap`, so the compiler-locked wiring sites keep schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors, while clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Connection authenticates settings reads, native actions, writes, `pickDirectory`, `openPath`, and every other Host operation with one browser session; Host/Origin failures still return 403 before identity is checked. +**Configuration calls use their owning wire implementation, rejections as codes, and owner events forwarded verbatim.** `@deepseek-ai/dsh-api-settings-controller` owns generated Remote methods for `settings/describe`, `settings/update`, `settings/replace`, `settings/mutate`, and `credentials/describe|set|unset`; `settings.openDocument` and the `llm.*` methods remain in `RpcMethodMap`. Provider absence retains the configuration API's actionable `internal` diagnostic, while seam rejections retain `settings-rejected {ns}` / `settings-conflict {ns, expected, actual}` / `credential-rejected {ref}`. Clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Connection authenticates generated Remote methods and API Proxy fallbacks with the same browser session; Host/Origin failures still return 403 before identity is checked. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 3c7f801766..0b15e32934 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,owner 事件原样转发。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models` 一同加入 `RpcMethodMap`,由编译器锁定的接线位点让 schema、处理器与客户端保持步调一致。seam 侧拒绝折叠为业务错误,客户端则订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。Connection 用一个浏览器会话认证 settings 读取、原生操作、写入、`pickDirectory`、`openPath` 与其他所有 Host 操作;Host/Origin 失败仍会在身份校验前返回 403。 +**配置调用使用其所属的 wire 实现,拒绝落为错误码,owner 事件原样转发。**`@deepseek-ai/dsh-api-settings-controller` 持有 `settings/describe`、`settings/update`、`settings/replace`、`settings/mutate` 与 `credentials/describe|set|unset` 的生成 Remote 方法;`settings.openDocument` 和 `llm.*` 方法仍位于 `RpcMethodMap`。provider 缺失时保留配置 API 可操作的 `internal` 诊断,seam 拒绝则保留 `settings-rejected {ns}`/`settings-conflict {ns, expected, actual}`/`credential-rejected {ref}`。Client 订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。Connection 使用同一个浏览器会话认证生成的 Remote 方法与 API Proxy 回退;Host/Origin 失败仍会在身份校验前返回 403。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index 67e708c2ff..f7a2725d96 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: cd019ef10c6d584a98b185ac50856a3fe63b8bd0 -2026-08-10-unary-apiproxy-remote-migration.zh.md: e6842cc82cfd7f850bb58e9d640ec23394287427 +2026-08-10-unary-apiproxy-remote-migration.md: b63946b581d3a2afcd159e3b1c0ef44f824aa35c +2026-08-10-unary-apiproxy-remote-migration.zh.md: 92f40fc79f2be44855620b6b6915798834284747 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md index cd019ef10c..b63946b581 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -29,7 +29,8 @@ Large BFF methods remain in `dsh-host-apiproxy`. A method leaves this migration | `session.rename` | `ctx.remote.sessionTitle` in `@deepseek-ai/dsh-session-title` | `SessionTitleService.rename(Session, title)` | Direct `@Remote`; Client maps `eventSeq` to its title projection sequence. | | `command.list`, `command.execute` | `ctx.remote.commands` in `@deepseek-ai/dsh-commands` | `CommandRuntime.list(Agent)`, `execute(Agent, line, signal)` | Direct `@Remote`; Client maps `undefined` to unmatched and preserves caller cancellation. | | `llm.providers` | `ctx.remote.llm` in `@deepseek-ai/dsh-llm` | `LlmRuntime.listProviders()`, `listConfigurableProviders()` | Direct `@Remote` on both reads; the Client joins registration and configuration-directory rows. | -| `credentials.describe`, `credentials.set`, `credentials.unset` | `ctx.remote.credentials` in `@deepseek-ai/dsh-credentials-local` | `LocalCredentialProvider.describe(ref)`, `set(ref, value)`, `unset(ref)` | Direct `@Remote`; Client batches `describe` calls when its UI requests several refs. | +| `credentials.describe`, `credentials.set`, `credentials.unset` | `ctx.remote.credentials` in `@deepseek-ai/dsh-api-settings-controller` | `CredentialsController.describe(refs)`, `set(ref, value)`, `unset(ref)` | The controller preserves batch size, reference validation, field projection, provider-absence diagnostics, and provider refusal mapping without adding wire behavior to the abstract Definition. | +| `settings.describe`, `settings.update`, `settings.replace`, `settings.mutate` | `ctx.remote.settings` in `@deepseek-ai/dsh-api-settings-controller` | `SettingsController.describe()`, `update(ns, patch, expectedRevision)`, `replace(ns, section, expectedRevision)`, `mutate(ns, ops, expectedRevision)` | The controller preserves redaction, all three write operations, optimistic revision checks, provider-absence diagnostics, and failure details. | | `agentPreset.read`, `agentPreset.copy`, `agentPreset.remove` | `ctx.remote.agentPresets` in `@deepseek-ai/dsh-agent-presets` | `readDocument(id)`, `copy(from, id, name?)`, `remove(id)` | `copy` and `remove` are direct; `readDocument` combines stored content with metadata from one live discovery. | | `subagent.interrupt` | `ctx.remote.subagents` in `@deepseek-ai/dsh-subagent` | `interruptByParent(targetSessionId, parentSessionId)` | Adapter constructs the internal user-authority variant without resolving or resuming either Agent. | | `workspace.list`, `workspace.insertSessionBefore`, `workspace.archiveSession` | `ctx.remote.workspace` in `@deepseek-ai/dsh-workspace` | `snapshot()`, `insertSessionBefore(workspaceId, sessionId, before?)`, `archiveSession(sessionId)` | Registry adapters detach mutable entities and return the settled workspace or archive snapshot. | @@ -44,7 +45,7 @@ The Remote API deliberately follows Service names rather than preserving dotted | Session transcript | `session.history`, `attachment`, `subagent.history` | Cold/live logs, pagination, projections, presenters, and attachment authorization. | | Agent model selection | `session.models`, `selectModel` | Per-Agent state, model validation, and default persistence are BFF policy. | | Agent input and control | `session.prompt`, `updateQueue`, `cancel` | Image admission, Inbox mutation, and endpoint-specific live-only semantics. | -| Configuration Remote | `settings.describe`, `openDocument`, `update`, `replace`, `mutate` | Namespace exposure, redaction, revision checks, and native opening are product policy. | +| Native settings document | `settings.openDocument` | Host path resolution, document preparation, and native opening remain product policy in API Proxy. | | Session skill catalog | `skill.list` | Cold Sessions must not resume; preset standing scope and presenter filtering are BFF joins. | | Host runtime information | `host.describe` | Version, cwd, default model, and attached count combine several Host owners. | | Host path opening | `host.openPath`, `agentPreset.openDocument` | Native desktop authority and cancellation belong to the Host composition. | @@ -72,7 +73,7 @@ Methods whose signatures contain only branded ids do not invoke Typert object lo ## Client and error behavior -Generated Remote methods return business values and throw an Error whose `cause` contains the existing RPC failure. Client business services own adaptation to their current result/store interfaces. They must settle successful results immediately exactly as the existing services do so event frames remain idempotent replays rather than the only update path. +Generated Remote methods return `RemoteResult` values. Client business services adapt them to their current stores and settle successful results immediately exactly as the existing services do, so event frames remain idempotent replays rather than the only update path. The migration preserves domain validation, provider-absence diagnostics, business error codes, structured details, and successful values; only endpoint addressing, the Remote result envelope, and the separately accepted timeout behavior differ from API Proxy transport. Resolver-owned `session-not-found` and `agent-busy` errors remain stable because the shared resolver raises `TypertLookupFailure`. Ordinary business exceptions become the Gateway's existing `internal` RPC failure. A selected Client consumer may migrate only if it does not branch on a more specific legacy business error code; if implementation finds such a branch, that RPC leaves this set unless the business package gains a transport-independent typed failure. diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index e6842cc82c..92f40fc79f 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -29,7 +29,8 @@ API Proxy 还包含一些不以业务方法为约定的 BFF 操作:Session 生 | `session.rename` | `ctx.remote.sessionTitle`,位于 `@deepseek-ai/dsh-session-title` | `SessionTitleService.rename(Session, title)` | 直接使用 `@Remote`;Client 将 `eventSeq` 映射到自身的标题投影序列。 | | `command.list`、`command.execute` | `ctx.remote.commands`,位于 `@deepseek-ai/dsh-commands` | `CommandRuntime.list(Agent)`、`execute(Agent, line, signal)` | 直接使用 `@Remote`;Client 将 `undefined` 映射为未匹配结果,并保留调用方的取消行为。 | | `llm.providers` | `ctx.remote.llm`,位于 `@deepseek-ai/dsh-llm` | `LlmRuntime.listProviders()`、`listConfigurableProviders()` | 两项读取都直接使用 `@Remote`;Client 关联注册行与配置目录行。 | -| `credentials.describe`、`credentials.set`、`credentials.unset` | `ctx.remote.credentials`,位于 `@deepseek-ai/dsh-credentials-local` | `LocalCredentialProvider.describe(ref)`、`set(ref, value)`、`unset(ref)` | 直接使用 `@Remote`;当 UI 请求多个 ref 时,Client 批量发起 `describe` 调用。 | +| `credentials.describe`、`credentials.set`、`credentials.unset` | `ctx.remote.credentials`,位于 `@deepseek-ai/dsh-api-settings-controller` | `CredentialsController.describe(refs)`、`set(ref, value)`、`unset(ref)` | controller 保留批量上限、引用校验、字段投影、provider 缺失诊断与 provider 拒绝映射,不给抽象 Definition 增加 wire 行为。 | +| `settings.describe`、`settings.update`、`settings.replace`、`settings.mutate` | `ctx.remote.settings`,位于 `@deepseek-ai/dsh-api-settings-controller` | `SettingsController.describe()`、`update(ns, patch, expectedRevision)`、`replace(ns, section, expectedRevision)`、`mutate(ns, ops, expectedRevision)` | controller 保留脱敏、三种写入操作、乐观 revision 校验、provider 缺失诊断与失败 details。 | | `agentPreset.read`、`agentPreset.copy`、`agentPreset.remove` | `ctx.remote.agentPresets`,位于 `@deepseek-ai/dsh-agent-presets` | `readDocument(id)`、`copy(from, id, name?)`、`remove(id)` | `copy` 和 `remove` 直接暴露现有方法;`readDocument` 将存储的内容与一次实时发现取得的元数据组合。 | | `subagent.interrupt` | `ctx.remote.subagents`,位于 `@deepseek-ai/dsh-subagent` | `interruptByParent(targetSessionId, parentSessionId)` | 适配器构造内部的用户权限变体,不解析也不恢复任一 Agent。 | | `workspace.list`、`workspace.insertSessionBefore`、`workspace.archiveSession` | `ctx.remote.workspace`,位于 `@deepseek-ai/dsh-workspace` | `snapshot()`、`insertSessionBefore(workspaceId, sessionId, before?)`、`archiveSession(sessionId)` | 注册表适配器分离可变实体,并返回已完成更新的 workspace 或归档快照。 | @@ -44,7 +45,7 @@ Remote API 有意采用服务名称,而不保留旧 RPC 的点分名称。例 | Session transcript | `session.history`、`attachment`、`subagent.history` | cold/live 日志、分页、投影、呈现器和附件授权。 | | Agent 模型选择 | `session.models`、`selectModel` | 各 Agent 的状态、模型校验和默认值持久化属于 BFF 策略。 | | Agent 输入与控制 | `session.prompt`、`updateQueue`、`cancel` | 图片准入、Inbox 变更和端点特有的仅限 live 语义。 | -| 配置 Remote | `settings.describe`、`openDocument`、`update`、`replace`、`mutate` | namespace 暴露、脱敏、修订检查和原生打开操作属于产品策略。 | +| 原生 settings 文档 | `settings.openDocument` | Host 路径解析、文档准备和原生打开仍属于 API Proxy 中的产品策略。 | | Session skill 目录 | `skill.list` | 不得恢复冷 Session;preset 的常驻 scope 和呈现器过滤属于 BFF 关联操作。 | | Host 运行时信息 | `host.describe` | 版本、cwd、默认模型和当前已附加的 Session 数量来自多个 Host 所有者。 | | Host 路径打开 | `host.openPath`、`agentPreset.openDocument` | 原生桌面权限和取消属于 Host 组合。 | @@ -72,7 +73,7 @@ Lookup 策略作用于整个 key,而非特定端点。提示词输入、队列 ## Client 与错误行为 -生成的 Remote 方法返回业务值,并抛出一个 Error,其 `cause` 包含现有的 RPC 失败。Client 业务服务负责适配到当前的结果/store 接口。它们必须与现有服务一样让成功结果立即生效,使事件帧仍是幂等回放,而非唯一的更新路径。 +生成的 Remote 方法返回 `RemoteResult` 值。Client 业务服务负责把它们适配到现有 store,并与既有服务一样让成功结果立即生效,使事件帧仍是幂等回放,而非唯一的更新路径。迁移保留领域校验、provider 缺失诊断、业务错误码、结构化 details 与成功值;只有 endpoint 寻址、Remote 结果信封和另行接受的超时行为不同于 API Proxy 传输。 Resolver 拥有的 `session-not-found` 和 `agent-busy` 错误保持稳定,因为共享 resolver 会抛出 `TypertLookupFailure`。普通业务异常会变成 Gateway 现有的 `internal` RPC 失败。只有在选定的 Client 消费方不根据更具体的旧版业务错误码进行分支时,才能迁移该调用;如果实现过程中发现这种分支,除非业务包新增与传输无关的类型化失败,否则该 RPC 将退出此集合。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 9d54d834a9..5d1d98a404 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 7cf93600cbf0d2deef586135f299058629732685 -capability-seams.zh.md: 3d79717d811fc3cd4e51c19c34c02a4e51d5da21 +capability-seams.md: 1e7e6e39d307a9e72b5d57420bde99f51063f64d +capability-seams.zh.md: e33a1e6da7f71838c48b961f93389ba1a089f57f diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 7cf93600cb..1e7e6e39d3 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -38,6 +38,9 @@ flowchart LR pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] svc_sessionController["ctx.sessionController
Host Session Remote controller"] + pkg_api_settings_controller["api-settings-controller"] + svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] + svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -218,6 +221,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_settings_controller --> svc_credentialsController + pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_attachment --> svc_attachments @@ -460,6 +465,8 @@ flowchart LR | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | +| `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | +| `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 3d79717d81..e33a1e6da7 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -40,6 +40,9 @@ flowchart LR pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] svc_sessionController["ctx.sessionController
Host Session Remote controller"] + pkg_api_settings_controller["api-settings-controller"] + svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] + svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -220,6 +223,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_settings_controller --> svc_credentialsController + pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_attachment --> svc_attachments @@ -462,6 +467,8 @@ flowchart LR | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | +| `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | +| `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2004935a78..4cdad8558b 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6e001376cfba7c04a07acd56ac733f089283f0bf -config-catalog.zh.md: 8b1ab084668ef8e1568801855f9aebc19e76a5e3 +config-catalog.md: 7f85b870bd9604ed983b0d8a251a3ee4511a52b7 +config-catalog.zh.md: 1ab6838e4cea77a7d98a2227aca6e8ac47d84fcd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6e001376cf..7f85b870bd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3319,6 +3319,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) +- `@deepseek-ai/dsh-api-settings-controller` ([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8b1ab08466..1ab6838e4c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3321,6 +3321,7 @@ export interface Config { - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) +- `@deepseek-ai/dsh-api-settings-controller`([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index ed46752463..dc55a4ee59 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: bcf7ec4f3418d6b598b7edecfb2e4386f1a42e97 -event-producer-consumer.zh.md: beba6bee877989ca2fc3d33d26a989be30069c33 +event-producer-consumer.md: 91bfb814a4370fdafb6d62bc79259495b0069dda +event-producer-consumer.zh.md: e7dcd089f28888bdb235ee0a6df97dc4189f0723 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bcf7ec4f34..91bfb814a4 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,8 +35,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | +| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | @@ -49,8 +49,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index beba6bee87..e7dcd089f2 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -37,8 +37,8 @@ | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | +| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | @@ -51,8 +51,8 @@ | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index afb151522f..8866f5a1f1 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 1668d509cea592acc6a6bfd15bb4ba0e5d55d5c8 -module-graph.zh.md: 03edacc1abfe4bfdc0037dd4712e0ce67a014103 +module-graph.md: 93170bd4d76d768d1cf5f1bc5efdf94d1ce2453a +module-graph.zh.md: ac7f7e70d2364469ce5de5bf0734457e8e4dfb3e diff --git a/docs/module-graph.md b/docs/module-graph.md index 1668d509ce..93170bd4d7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -108,6 +108,7 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] + pkg_api_settings_controller["api-settings-controller"] pkg_api_workspace_controller["api-workspace-controller"] end subgraph group_attachment["packages/attachment"] @@ -410,8 +411,6 @@ flowchart TD pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants - pkg_settings --> pkg_brand - pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants @@ -438,10 +437,6 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment - pkg_settings_file --> pkg_atomic_write - pkg_settings_file --> pkg_home_paths - pkg_settings_file --> pkg_invariants - pkg_settings_file --> pkg_settings pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -504,6 +499,9 @@ flowchart TD pkg_session_persistence --> pkg_timeout pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session + pkg_settings --> pkg_brand + pkg_settings --> pkg_invariants + pkg_settings --> pkg_session pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_llm_retry --> pkg_agent @@ -537,6 +535,11 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_api_settings_controller --> pkg_credentials + pkg_api_settings_controller --> pkg_invariants + pkg_api_settings_controller --> pkg_session + pkg_api_settings_controller --> pkg_settings + pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants pkg_file_reference --> pkg_typert_protocol @@ -605,6 +608,10 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection + pkg_settings_file --> pkg_atomic_write + pkg_settings_file --> pkg_home_paths + pkg_settings_file --> pkg_invariants + pkg_settings_file --> pkg_settings pkg_shell --> pkg_invariants pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings @@ -1088,6 +1095,7 @@ flowchart TD pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_settings pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands @@ -1292,6 +1300,7 @@ flowchart TD pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_settings_controller pkg_api_remotes --> pkg_api_workspace_controller pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner @@ -1334,7 +1343,6 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes - pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1616,7 +1624,6 @@ flowchart TD pkg_client_ui_model_selection --> pkg_typert_protocol pkg_client_ui_permission_presets --> pkg_api_remotes pkg_client_ui_permission_presets --> pkg_api_session_controller - pkg_client_ui_permission_presets --> pkg_client_connection pkg_client_ui_permission_presets --> pkg_client_locale pkg_client_ui_permission_presets --> pkg_client_ui_commands pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger @@ -1728,7 +1735,6 @@ flowchart TD | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1738,7 +1744,6 @@ flowchart TD | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | -| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1758,6 +1763,7 @@ flowchart TD | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | @@ -1765,6 +1771,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | @@ -1780,6 +1787,7 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1858,7 +1866,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1886,13 +1894,13 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1922,7 +1930,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 03edacc1ab..ac7f7e70d2 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -110,6 +110,7 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] + pkg_api_settings_controller["api-settings-controller"] pkg_api_workspace_controller["api-workspace-controller"] end subgraph group_attachment["packages/attachment"] @@ -412,8 +413,6 @@ flowchart TD pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants - pkg_settings --> pkg_brand - pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants @@ -440,10 +439,6 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment - pkg_settings_file --> pkg_atomic_write - pkg_settings_file --> pkg_home_paths - pkg_settings_file --> pkg_invariants - pkg_settings_file --> pkg_settings pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -506,6 +501,9 @@ flowchart TD pkg_session_persistence --> pkg_timeout pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session + pkg_settings --> pkg_brand + pkg_settings --> pkg_invariants + pkg_settings --> pkg_session pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_llm_retry --> pkg_agent @@ -539,6 +537,11 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_api_settings_controller --> pkg_credentials + pkg_api_settings_controller --> pkg_invariants + pkg_api_settings_controller --> pkg_session + pkg_api_settings_controller --> pkg_settings + pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants pkg_file_reference --> pkg_typert_protocol @@ -607,6 +610,10 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection + pkg_settings_file --> pkg_atomic_write + pkg_settings_file --> pkg_home_paths + pkg_settings_file --> pkg_invariants + pkg_settings_file --> pkg_settings pkg_shell --> pkg_invariants pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings @@ -1090,6 +1097,7 @@ flowchart TD pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_settings pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands @@ -1294,6 +1302,7 @@ flowchart TD pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_settings_controller pkg_api_remotes --> pkg_api_workspace_controller pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner @@ -1336,7 +1345,6 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes - pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1618,7 +1626,6 @@ flowchart TD pkg_client_ui_model_selection --> pkg_typert_protocol pkg_client_ui_permission_presets --> pkg_api_remotes pkg_client_ui_permission_presets --> pkg_api_session_controller - pkg_client_ui_permission_presets --> pkg_client_connection pkg_client_ui_permission_presets --> pkg_client_locale pkg_client_ui_permission_presets --> pkg_client_ui_commands pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger @@ -1730,7 +1737,6 @@ flowchart TD | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1740,7 +1746,6 @@ flowchart TD | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | -| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1760,6 +1765,7 @@ flowchart TD | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | @@ -1767,6 +1773,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | @@ -1782,6 +1789,7 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1860,7 +1868,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1888,13 +1896,13 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1924,7 +1932,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 41c7b34cc4..23c65ec57f 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/credentials.md -credentials.md: 9955f3ae6c6991ba5abbbec593ca5ccd12ef8773 -credentials.zh.md: 5565fe89431da42cd50fabca48bcc53e0810c800 +credentials.md: 90c10aa65f4b559eb72d4973945272d8d9322649 +credentials.zh.md: 9e3f2b8cd046e8245145f778a6c8fd6663b12f90 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 9955f3ae6c..90c10aa65f 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -34,13 +34,17 @@ interface ResolvedCredential { `describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front. ```ts type-equiv -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +/** + * Source and writability facts for one reference, safe for configuration UIs — + * never the value. The view has no slot a value could ride in, which is what + * lets the whole read half cross the Remote wire. + */ interface CredentialInfo { - /** Whether {@link CredentialProvider.resolve} would currently return a value. */ + /** Whether `CredentialProvider.resolve` would currently return a value. */ configured: boolean /** Source layer currently supplying the value; absent while unconfigured. */ source?: string - /** Whether {@link CredentialProvider.set} would currently succeed for this reference. */ + /** Whether `CredentialProvider.set` would currently succeed for this reference. */ writable: boolean } ``` @@ -212,6 +216,42 @@ abstract deleteRecord(key: CredentialKey): Promise Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + + +### `ctx.credentialsController` — `CredentialsController` + +Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one. + +```ts cordis-catalog +/** + * Describe several references for one configuration surface. Batched because + * a settings page describes every reference its rows name at once, and one + * round trip keeps those rows from settling separately. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`. + * @returns one view per requested name, keyed by that name. + * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted. + */ +@Remote async describe(refs: string[]): Promise> + +/** + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. + * @param ref - reference name to store under. + * @param value - the non-empty secret value. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote async set(ref: string, value: string): Promise + +/** + * Remove one reference from a configuration surface. + * @param ref - reference name to remove. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote async unset(ref: string): Promise +``` + +Source: [`packages/api/settings-controller/src/credentials.ts`](../../packages/api/settings-controller/src/credentials.ts) + ### `authorization/*` events diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 5565fe8943..9e3f2b8cd0 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -34,13 +34,17 @@ interface ResolvedCredential { `describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地提供方把由当前进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。 ```ts type-equiv -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +/** + * Source and writability facts for one reference, safe for configuration UIs — + * never the value. The view has no slot a value could ride in, which is what + * lets the whole read half cross the Remote wire. + */ interface CredentialInfo { - /** Whether {@link CredentialProvider.resolve} would currently return a value. */ + /** Whether `CredentialProvider.resolve` would currently return a value. */ configured: boolean /** Source layer currently supplying the value; absent while unconfigured. */ source?: string - /** Whether {@link CredentialProvider.set} would currently succeed for this reference. */ + /** Whether `CredentialProvider.set` would currently succeed for this reference. */ writable: boolean } ``` @@ -212,6 +216,42 @@ abstract deleteRecord(key: CredentialKey): Promise Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + + +### `ctx.credentialsController` — `CredentialsController` + +Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one. + +```ts cordis-catalog +/** + * Describe several references for one configuration surface. Batched because + * a settings page describes every reference its rows name at once, and one + * round trip keeps those rows from settling separately. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`. + * @returns one view per requested name, keyed by that name. + * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted. + */ +@Remote async describe(refs: string[]): Promise> + +/** + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. + * @param ref - reference name to store under. + * @param value - the non-empty secret value. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote async set(ref: string, value: string): Promise + +/** + * Remove one reference from a configuration surface. + * @param ref - reference name to remove. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote async unset(ref: string): Promise +``` + +Source: [`packages/api/settings-controller/src/credentials.ts`](../../packages/api/settings-controller/src/credentials.ts) + ### `authorization/*` events diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index a0045a52ca..0c345cf749 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/settings.md -settings.md: bfda7b897031337090f78e692a881a3d8e4cb625 -settings.zh.md: 06e82d6d25ea95ac82072ac4fa0e93d90cc4d948 +settings.md: 59bd06a6e7a8659097c726b85db13caa46dd461f +settings.zh.md: a6bbdbce633a8b51167615f7838e55b698d48829 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index bfda7b8970..59bd06a6e7 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -254,6 +254,56 @@ async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevi Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) + + +### `ctx.settingsController` — `SettingsController` + +Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role('secret')` field cannot ride a response. Writes expose the settings service's merge, replacement, and path-addressed operations, and classify every provider refusal as `settings-conflict` or `settings-rejected` with the service's message. + +```ts cordis-catalog +/** + * Describe every registered namespace for a configuration page: redacted + * layered values plus the serialized schema the page renders its form from. + * @returns provider writability, local-document presence, and one view per namespace. + * @throws TypertRemoteFailure when no settings provider is mounted. + */ +@Remote describe(): SettingsDescribeValue + +/** + * Merge a patch into one namespace's stored user section. + * @param ns - namespace key to write. + * @param patch - fields to merge into the user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise + +/** + * Replace one namespace's stored user section wholesale. + * @param ns - namespace key to write. + * @param section - complete replacement user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise + +/** + * Apply path-addressed edits to one namespace's user section, resolved against + * the section as stored rather than against whatever the caller last read, + * then answer with that namespace's new redacted view. + * @param ns - namespace key to write. + * @param ops - the edits to apply, in order. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise +``` + +Source: [`packages/api/settings-controller/src/index.ts`](../../packages/api/settings-controller/src/index.ts) + ### `settings/*` events diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 06e82d6d25..a6bbdbce63 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -254,6 +254,56 @@ async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevi Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) + + +### `ctx.settingsController` — `SettingsController` + +Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role('secret')` field cannot ride a response. Writes expose the settings service's merge, replacement, and path-addressed operations, and classify every provider refusal as `settings-conflict` or `settings-rejected` with the service's message. + +```ts cordis-catalog +/** + * Describe every registered namespace for a configuration page: redacted + * layered values plus the serialized schema the page renders its form from. + * @returns provider writability, local-document presence, and one view per namespace. + * @throws TypertRemoteFailure when no settings provider is mounted. + */ +@Remote describe(): SettingsDescribeValue + +/** + * Merge a patch into one namespace's stored user section. + * @param ns - namespace key to write. + * @param patch - fields to merge into the user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise + +/** + * Replace one namespace's stored user section wholesale. + * @param ns - namespace key to write. + * @param section - complete replacement user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise + +/** + * Apply path-addressed edits to one namespace's user section, resolved against + * the section as stored rather than against whatever the caller last read, + * then answer with that namespace's new redacted view. + * @param ns - namespace key to write. + * @param ops - the edits to apply, in order. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ +@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise +``` + +Source: [`packages/api/settings-controller/src/index.ts`](../../packages/api/settings-controller/src/index.ts) + ### `settings/*` events diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 37fe0e96a9..b93fd190e7 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/README.md -README.md: 1d9bcc49684ee557bc62b8ba2bbe79918c5de5ca -README.zh.md: 2aae832f8dc9ece0f15283efded7c9c05b5bff88 +README.md: aaea6ef47aabdc8baa63ef7a875de065bcae94b9 +README.zh.md: 8e87f97c413620ede4ee160384d00f18382cfdf7 diff --git a/packages/api/README.md b/packages/api/README.md index 1d9bcc4968..aaea6ef47a 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -29,9 +29,10 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | | [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | +| [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | -Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the two controller packages own Session and Workspace behavior. Endpoints without a Remote definition fall through to the application's API Proxy. +Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Endpoints without a Remote definition fall through to the application's API Proxy. ----- diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 2aae832f8d..8e87f97c41 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -22,16 +22,17 @@ kind: "package-group" ## 包 -下面两个包共同提供 Remote 层;穷尽式约定以各包 README 为准。 +下面这些包共同提供 Remote 层;穷尽式约定以各包 README 为准。 | 包 | 职责 | ctx key | |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | | [`gateway/`](gateway/README.zh.md) | 承载带类型的单次调用、多路复用 stream 与转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | | [`session-controller/`](session-controller/README.zh.md) | 拥有 Session 命令、历史 stream、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | +| [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | -Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,两个 controller 包分别拥有 Session 与 Workspace 行为。没有 Remote 定义的 endpoint 会回退到应用的 API Proxy。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。没有 Remote 定义的 endpoint 会回退到应用的 API Proxy。 ----- diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 4cd5c6c9ea..c1158e8aa7 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: 7817f30c531abffeb9156e1afaf8c6fe0697431e -README.zh.md: 4529ee60289fc43978f9526d4abb3e2cbf073687 +README.md: 50361d1a6ae2f0b3b53a5bd928429414ab12bbce +README.zh.md: 5545198cce2e43513af73adfc9d4277831adae92 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 7817f30c53..50361d1a6a 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -27,7 +27,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package only selects and mounts that generated Session contribution; it does not duplicate activation policy. -The Client assembly mounts Commands, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, Session Controller, and Workspace Controller contributions. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The Client assembly mounts Commands, credentials, settings, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, Session Controller, and Workspace Controller contributions. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This package owns no physical transport or Host service discovery. It projects the application selection into generated Remote contributions and an independent Host event source per Client; API Gateway owns endpoints, carriers, cancellation, and reconnection. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 4529ee6028..5545198cce 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 Agent 与 Session 身份策略,包括供其他 namespace 使用的 Typert lookup resolver。本包只选择并挂载生成的 Session contribution,不复制激活策略。 -Client 组合挂载 Commands、Goal、动态 Cordis、文件与 Session 引用、只读 Host 插件清单、消息反馈、Session Controller 和 Workspace Controller contribution。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件与 Session 引用、只读 Host 插件清单、消息反馈、Session Controller 和 Workspace Controller contribution。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution 和唯一的 Host Cordis event source;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml new file mode 100644 index 0000000000..8cab324b55 --- /dev/null +++ b/packages/api/settings-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/settings-controller/README.md +README.md: 71a44ce015f5ecd6c002d29f95df984281851c1a +README.zh.md: 5776013f3d891cee59d7e0cc0f3f92dc02a15530 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md new file mode 100644 index 0000000000..f57bab1cf6 --- /dev/null +++ b/packages/api/settings-controller/README.md @@ -0,0 +1,57 @@ +--- +description: "Host Remote owner for settings and credential configuration surfaces, including redacted reads, path-addressed settings writes, and credential reference management." +kind: "package-reference" +--- +# Settings Controller + +English | [中文](README.zh.md) + +## Summary + +`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata, supports merge, replacement, and path-addressed settings writes, and stores or removes credential references without returning secret values. When either provider is absent, its namespace remains registered and returns an actionable configuration error. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount this package as a Loader entry in a profile that serves browser configuration. The entry registers both namespaces independently of their providers so a missing provider produces a named configuration error at invocation. Its generated descriptors enter the strict Typert registry, while the settings and credential Definitions remain plain Cordis Services with no wire obligations of their own. + +`describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. Secret values cross in this direction only: no method here returns one. + +`settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. + +----- + + +## Model Experience + +None, as settings and credential configuration are browser and Host state and register no prompt, tool, or session event. + +#### KV Cache effect + +No direct effect; reading or writing these configuration values does not alter model requests already in flight. + +## Known Limitations and Deferred Work + + + +- Settings document opening uses API Proxy rather than this Remote namespace. +- The batch bound is fixed at 64 references and is not a deployment-configurable field. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md new file mode 100644 index 0000000000..41062db004 --- /dev/null +++ b/packages/api/settings-controller/README.zh.md @@ -0,0 +1,57 @@ +--- +description: "settings 与凭据配置界面的 Host Remote owner,涵盖脱敏读取、按路径写入 settings 和管理凭据引用。" +kind: "package-reference" +--- +# Settings Controller + +[English](README.md) | 中文 + +## 概述 + +`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持合并、替换和按路径表达的 settings 写入,并在不返回密钥值的前提下写入或移除凭据引用。任一 provider 缺失时,对应 namespace 仍会注册,并返回可操作的配置错误。 + +## 目录 + +- [使用本包](#use-this-package) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +请把本包作为 Loader entry 挂载到提供浏览器配置的 profile 中。本 entry 不依赖 provider 是否存在而注册两个 namespace,因此缺少 provider 会在调用时产生具名配置错误。它生成的 descriptor 进入严格 Typert registry,而 settings 与凭据 Definition 仍是普通 Cordis Service,自身不承担任何 wire 义务。 + +`describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——provider 返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把 provider 拒绝报告为 `credential-rejected`,携带 provider 的消息,details 中只有该引用。密钥值只在这个方向跨越 wire:这里没有任何方法会返回它。 + +`settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings service 的三种写入操作,并返回该 namespace 的新脱敏视图;过期写入使用 `settings-conflict`,其他 provider 拒绝使用 `settings-rejected`。 + +----- + + +## 模型体验 + +无,因为 settings 与凭据配置属于浏览器和 Host 状态,并且不注册提示词、工具或会话事件。 + +#### KV Cache 影响 + +无直接影响;读取或写入这些配置值不会改变已经在途的模型请求。 + +## 已知限制与延期工作 + + + +- settings 文档打开使用 API Proxy,而不经过本 Remote namespace。 +- 批量上限固定为 64 个引用,不是可按部署配置的字段。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml index ffcf951b90..d05faf7d8b 100644 --- a/packages/credentials/credentials/README.i18n.yaml +++ b/packages/credentials/credentials/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials/README.md -README.md: 08533666a08a8440edb25b8bfcbcecac0051e9bc -README.zh.md: 3eee35acddb1490cad21c17e5c6173e6e56fc961 +README.md: 30eff7799e664b08224e2d9732b2fcee4867ad98 +README.zh.md: d20050324106cec46e6ae7d7a13b4ece085ab893 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 08533666a0..30eff7799e 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -130,13 +130,13 @@ One doctrine and four consequences: | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Service Definition: the `credentialRef`/`credentialKey` brands, `ResolvedCredential`/`CredentialInfo`/`CredentialRecordInfo`, the abstract provider over both key spaces, contained fan-out | -| [`src/types.ts`](src/types.ts) | Client-safe type surface: the `CredentialRef` and `CredentialKey` brands, the stored-record union, the `credentials/reference-updated` and `credentials/record-updated` declarations | +| [`src/index.ts`](src/index.ts) | Service Definition: the `credentialRef`/`credentialKey` brands, `ResolvedCredential`/`CredentialRecordInfo`, the abstract provider over both key spaces, contained fan-out | +| [`src/types.ts`](src/types.ts) | Client-safe type surface: the `CredentialRef` and `CredentialKey` brands, the stored-record union, the `CredentialInfo` reference view, the `credentials/reference-updated` and `credentials/record-updated` declarations | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: `credentials/reference-updated` only fires while a credentials service is live | ### Client-safe types -The `./types` subpath export holds the event declarations together with the `CredentialRef` and `CredentialKey` brands and the stored-record union they name, and the package root re-exports them. A consumer outside the Host compilation face therefore reads the very signature the Host emits instead of restating it. +The `./types` subpath export holds the event declarations together with the `CredentialRef` and `CredentialKey` brands, the stored-record union they name, and the `CredentialInfo` reference view a configuration surface reads, and the package root re-exports them. A consumer outside the Host compilation face therefore reads the very signature the Host emits instead of restating it. ### Lifecycle diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index 3eee35acdd..d200503241 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -130,13 +130,13 @@ apiKeyEnv: DEEPSEEK_API_KEY | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | Service Definition:`credentialRef`/`credentialKey` 品牌、`ResolvedCredential`/`CredentialInfo`/`CredentialRecordInfo`、覆盖两个键空间的抽象提供方、包含式扇出 | -| [`src/types.ts`](src/types.ts) | 客户端安全类型面:`CredentialRef` 与 `CredentialKey` 品牌、存储记录联合类型、`credentials/reference-updated` 与 `credentials/record-updated` 事件声明 | +| [`src/index.ts`](src/index.ts) | Service Definition:`credentialRef`/`credentialKey` 品牌、`ResolvedCredential`/`CredentialRecordInfo`、覆盖两个键空间的抽象提供方、包含式扇出 | +| [`src/types.ts`](src/types.ts) | 客户端安全类型面:`CredentialRef` 与 `CredentialKey` 品牌、存储记录联合类型、`CredentialInfo` 引用视图、`credentials/reference-updated` 与 `credentials/record-updated` 事件声明 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:`credentials/reference-updated` 只在凭据服务存活时触发 | ### 客户端安全类型 -`./types` 子路径出口把事件声明与其点名的 `CredentialRef`、`CredentialKey` 品牌及存储记录联合类型放在一起,包根继续 re-export 它们。于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。 +`./types` 子路径出口把事件声明与其点名的 `CredentialRef`、`CredentialKey` 品牌、存储记录联合类型,以及配置界面读取的 `CredentialInfo` 引用视图放在一起,包根继续 re-export 它们。于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。 ### 生命周期 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index db9c6a286b..600040a06f 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 9f96999faf26090ccb58763a9301381a4d21d2de -README.zh.md: 3633880a27e23c2c35706d97b8c905f24a17882d +README.md: 6586cee0b7edeb9ba0db63e9a0a13afc7323d9a5 +README.zh.md: c8984783b1fc1dcceec9bfb3b247a8805319726f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 9f96999faf..6586cee0b7 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Every client of the web GUI host calls one typed API through `dsh-host-apiproxy` — sessions and history, workspaces, directory picking, model selection, agent presets, skills, goals, settings, credentials, LLM catalogs, events, and session export — moved over HTTP or in-process by fetch carriers. The contract layer has zero Node dependencies and imports from the browser, so one typed API serves the Web server, Electron, and any future client shape. The shipped Web composition assembles the gateway in [`dsh-web-app`](../../bundle/web-app/README.md). Choosing a carrier, calling the domain APIs, and configuring the gateway come first; the wire protocol internals live in a collapsible developer section below. +Every client of the web GUI host calls one typed API through `dsh-host-apiproxy` — sessions and history, workspaces, directory picking, model selection, agent presets, skills, goals, settings, LLM catalogs, events, and session export — moved over HTTP or in-process by fetch carriers. The contract layer has zero Node dependencies and imports from the browser, so one typed API serves the Web server, Electron, and any future client shape. The shipped Web composition assembles the gateway in [`dsh-web-app`](../../bundle/web-app/README.md). Choosing a carrier, calling the domain APIs, and configuring the gateway come first; the wire protocol internals live in a collapsible developer section below. ## Table of Contents @@ -40,7 +40,7 @@ The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross ### What the gateway exposes -The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. +The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, openPath), `skills`, `agentPresets`, `goals`, `settings`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. ### Sessions and history @@ -56,7 +56,7 @@ The API is grouped into domains: `sessions` (list, create, history, prompt, canc ### Model selection, presets, commands, and configuration -`session.models` reports the current `ModelSelection` separately from provider-grouped advisory models, and `session.selectModel` saves an accepted switch as the deployment default through the shared `agent-default-model` settings section — a default naming an unavailable provider still reaches the selector as `current` instead of being silently replaced. Each access resolves an in-process selection first, then the session's latest `request/header`, then the deployment default. A logged reasoning effort marked as an adapter default remains absent from the restored selection, so the next resolution does not promote it into an explicit choice or record a false header change. `agentPreset.list` exposes the deployment's preset roster with each row's `trust` and a `broken` reason when a preset cannot compose a session; `agentPreset.select` swaps a blank session's composition and is refused once a turn has run. `skill.list` serves the composer menu with each skill's `modelInvocable` flag, and `command.execute` runs a slash command with pure admission semantics whose outcome rides the logged `command/run`/`command/done` pair. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire: `settings.describe` returns each namespace's schema and redacted layered values, `settings.mutate` is the removal path for a client holding the redacted view, secrets never ride any response, and `llm.discoverModels` interrogates a provider endpoint the page is still drafting without writing anything. +`session.models` reports the current `ModelSelection` separately from provider-grouped advisory models, and `session.selectModel` saves an accepted switch as the deployment default through the shared `agent-default-model` settings section — a default naming an unavailable provider still reaches the selector as `current` instead of being silently replaced. Each access resolves an in-process selection first, then the session's latest `request/header`, then the deployment default. A logged reasoning effort marked as an adapter default remains absent from the restored selection, so the next resolution does not promote it into an explicit choice or record a false header change. `agentPreset.list` exposes the deployment's preset roster with each row's `trust` and a `broken` reason when a preset cannot compose a session; `agentPreset.select` swaps a blank session's composition and is refused once a turn has run. `skill.list` serves the composer menu with each skill's `modelInvocable` flag, and `command.execute` runs a slash command with pure admission semantics whose outcome rides the logged `command/run`/`command/done` pair. The remaining configuration operations are `settings.openDocument` and the `llm.*` domain; generated settings and credential methods belong to [`@deepseek-ai/dsh-api-settings-controller`](../../api/settings-controller/README.md). ### Configuration @@ -92,7 +92,7 @@ The package is built on one separation: the API contract is channel-independent, ### The gateway service -`ApiProxyService` provides `ctx.apiProxy` and implements the contract over the composed host context — sessions, workspace registry, directory picker, agent presets, settings, credentials, LLM, events, and downloads. The Host cwd is the default project directory. The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata `host.describe` reports; `session.selectModel` (Session Controller) saves an accepted switch as the deployment default through the shared agent-default-model settings section. Product `dsh --profile headless` is a direct core entry point and does not mount this package. +`ApiProxyService` provides `ctx.apiProxy` and implements the contract over the composed host context — sessions, workspace registry, directory picker, agent presets, settings, LLM, events, and downloads. The Host cwd is the default project directory. The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata `host.describe` reports; `session.selectModel` (Session Controller) saves an accepted switch as the deployment default through the shared agent-default-model settings section. Product `dsh --profile headless` is a direct core entry point and does not mount this package. ### Request flow diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 3633880a27..c8984783b1 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -web GUI 宿主的每个客户端都通过 `dsh-host-apiproxy` 调用同一套类型化 API——会话与历史、工作区、目录选择、模型选择、agent preset、skill、目标、设置、凭据、LLM 目录、事件与会话导出——由 fetch 载体经由 HTTP 或进程内搬运。约定层零 Node 依赖、可从浏览器导入,因此一套类型化 API 同时服务 Web 服务器、Electron 与任何未来的客户端形态。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装网关。选择载体、调用领域 API 与配置网关在前;协议内部细节放在下方可折叠的开发者章节中。 +web GUI 宿主的每个客户端都通过 `dsh-host-apiproxy` 调用同一套类型化 API——会话与历史、工作区、目录选择、模型选择、agent preset、skill、目标、设置、LLM 目录、事件与会话导出——由 fetch 载体经由 HTTP 或进程内搬运。约定层零 Node 依赖、可从浏览器导入,因此一套类型化 API 同时服务 Web 服务器、Electron 与任何未来的客户端形态。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装网关。选择载体、调用领域 API 与配置网关在前;协议内部细节放在下方可折叠的开发者章节中。 ## 目录 @@ -40,7 +40,7 @@ HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站 ### 网关暴露什么 -API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 +API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 ### 会话与历史 @@ -56,7 +56,7 @@ API 按领域分组:`sessions`(list、create、history、prompt、cancel、q ### 模型选择、preset、命令与配置 -`session.models` 把当前 `ModelSelection` 与按提供方分组的咨询模型分开报告,`session.selectModel` 通过共享的 `agent-default-model` settings 分节把已接受的切换保存为部署默认值——指向不可用提供方的默认值仍会作为 `current` 送到选择器,而不是被静默替换。每次访问都先解析进程内选择,再读会话最新的 `request/header`,最后使用部署默认值。日志中标记为适配器默认值的推理强度不会进入恢复后的选择,因此下一次解析不会把它提升为显式选择,也不会记录虚假 header 变更。`agentPreset.list` 暴露部署的 preset 名单,每行带 `trust`,preset 无法组合会话时带 `broken` 原因;`agentPreset.select` 替换空白会话的组合,一旦跑过一轮即被拒绝。`skill.list` 为 composer 菜单提供每个 skill 的 `modelInvocable` 标志,`command.execute` 以纯准入语义运行斜杠命令,其结局由落账的 `command/run`/`command/done` 事件对承载。`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议:`settings.describe` 返回每个 namespace 的 schema 与脱敏后的分层值,`settings.mutate` 是持有脱敏视图的客户端的删除路径,secret 绝不搭乘任何响应,`llm.discoverModels` 询问页面尚在起草的提供方端点而不写任何东西。 +`session.models` 把当前 `ModelSelection` 与按提供方分组的咨询模型分开报告,`session.selectModel` 通过共享的 `agent-default-model` settings 分节把已接受的切换保存为部署默认值——指向不可用提供方的默认值仍会作为 `current` 送到选择器,而不是被静默替换。每次访问都先解析进程内选择,再读会话最新的 `request/header`,最后使用部署默认值。日志中标记为适配器默认值的推理强度不会进入恢复后的选择,因此下一次解析不会把它提升为显式选择,也不会记录虚假 header 变更。`agentPreset.list` 暴露部署的 preset 名单,每行带 `trust`,preset 无法组合会话时带 `broken` 原因;`agentPreset.select` 替换空白会话的组合,一旦跑过一轮即被拒绝。`skill.list` 为 composer 菜单提供每个 skill 的 `modelInvocable` 标志,`command.execute` 以纯准入语义运行斜杠命令,其结局由落账的 `command/run`/`command/done` 事件对承载。剩余配置操作是 `settings.openDocument` 与 `llm.*` 领域;生成的 settings 与凭据方法归 [`@deepseek-ai/dsh-api-settings-controller`](../../api/settings-controller/README.zh.md) 所有。 ### 配置 @@ -92,7 +92,7 @@ API 按领域分组:`sessions`(list、create、history、prompt、cancel、q ### 网关服务 -`ApiProxyService` 提供 `ctx.apiProxy`,并基于所组合的宿主上下文实现约定——会话、工作区注册表、目录选择器、agent preset、设置、凭据、LLM、事件与下载。Host cwd 是默认项目目录。网关只在 `host.describe` 报告的部署元数据中消费 `ctx.agentDefaultModel`;保存已接受的切换由 Session Controller 的 `session.selectModel` 通过共享的 agent-default-model settings 分节完成。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 +`ApiProxyService` 提供 `ctx.apiProxy`,并基于所组合的宿主上下文实现约定——会话、工作区注册表、目录选择器、agent preset、设置、LLM、事件与下载。Host cwd 是默认项目目录。网关只在 `host.describe` 报告的部署元数据中消费 `ctx.agentDefaultModel`;保存已接受的切换由 Session Controller 的 `session.selectModel` 通过共享的 agent-default-model settings 分节完成。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 ### 请求流 From dd70c0c88d84b72f943ddd66acefd3638c02066e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:00:49 +0800 Subject: [PATCH 11/18] feat(api): serve settings through Remote controllers --- packages/api/settings-controller/package.json | 70 +++++ .../settings-controller/src/credentials.ts | 157 ++++++++++++ packages/api/settings-controller/src/index.ts | 222 ++++++++++++++++ .../api/settings-controller/src/invariant.ts | 23 ++ packages/api/settings-controller/src/types.ts | 49 ++++ .../tests/credentials-controller.host.spec.ts | 141 ++++++++++ .../tests/invariant.spec.ts | 16 ++ .../tests/settings-controller.host.spec.ts | 241 ++++++++++++++++++ .../api/settings-controller/tsconfig.json | 30 +++ packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + packages/credentials/credentials/src/index.ts | 16 +- packages/credentials/credentials/src/types.ts | 21 +- packages/settings/settings/package.json | 6 +- packages/settings/settings/src/types.ts | 61 ++++- packages/settings/settings/tsconfig.json | 3 + pnpm-lock.yaml | 49 +++- scripts/gen-cordis-catalog.ts | 6 + scripts/gen-doc-graphs.ts | 14 + scripts/type-equiv.manifest.json | 2 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + tsconfig.host.json | 1 + 23 files changed, 1109 insertions(+), 29 deletions(-) create mode 100644 packages/api/settings-controller/package.json create mode 100644 packages/api/settings-controller/src/credentials.ts create mode 100644 packages/api/settings-controller/src/index.ts create mode 100644 packages/api/settings-controller/src/invariant.ts create mode 100644 packages/api/settings-controller/src/types.ts create mode 100644 packages/api/settings-controller/tests/credentials-controller.host.spec.ts create mode 100644 packages/api/settings-controller/tests/invariant.spec.ts create mode 100644 packages/api/settings-controller/tests/settings-controller.host.spec.ts create mode 100644 packages/api/settings-controller/tsconfig.json diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json new file mode 100644 index 0000000000..ed518756fb --- /dev/null +++ b/packages/api/settings-controller/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-api-settings-controller", + "description": "Remote owner for the configuration surfaces over the settings-domain seams", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/settings-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + } +} diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts new file mode 100644 index 0000000000..a9db113e35 --- /dev/null +++ b/packages/api/settings-controller/src/credentials.ts @@ -0,0 +1,157 @@ +/** + * Host owner of the `credentials` Remote namespace: the reference half of + * `ctx.credentials` as a browser configuration page reads and writes it. + * + * @module @deepseek-ai/dsh-api-settings-controller/src/credentials.ts + */ + +import { Context } from '@deepseek-ai/cordis' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' +import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { z } from 'zod' + +/** + * Fan-out bound on one remote `describe` batch. A settings page asks about the + * references its own rows name, so this is far above any real page and still + * keeps one authenticated request from starting unbounded provider work. + */ +const MAX_DESCRIBE_REFS = 64 + +const credentialRefSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/) +const describeRequestSchema = z.object({ + refs: z.array(credentialRefSchema).max(MAX_DESCRIBE_REFS), +}) +const setRequestSchema = z.object({ ref: credentialRefSchema, value: z.string().min(1) }) +const unsetRequestSchema = z.object({ ref: credentialRefSchema }) + +/** Parse the domain constraints that are more specific than generated TypeScript codecs. */ +function parseRequest(method: string, schema: z.ZodType, value: unknown): T { + const parsed = schema.safeParse(value) + if (!parsed.success) { + throw new TypertRemoteFailure({ + code: 'bad-request', + message: `invalid payload for ${method}`, + details: { issues: parsed.error.issues }, + }) + } + return parsed.data +} + +/** + * Copy exactly the fields {@link CredentialInfo} declares. The Gateway returns + * a business result without decoding it, so a provider whose `describe` carried + * extra enumerable properties would otherwise serialize them to the caller. + * @param info - the provider's answer for one reference. + * @returns the same facts with nothing else attached. + */ +function projectCredentialInfo(info: CredentialInfo): CredentialInfo { + return { + configured: info.configured, + ...info.source === undefined ? {} : { source: info.source }, + writable: info.writable, + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host owner of the `credentials` Remote namespace. */ + credentialsController: CredentialsController + } +} + +/** + * Host service backing the generated `ctx.remote.credentials` namespace. It + * carries every wire obligation the credential seam itself does not: the batch + * fan-out bound, the field-by-field view projection, the reference-grammar + * guard, and the refusal mapping. Secret values cross in one direction only — + * no method here returns one. + */ +export class CredentialsController extends TypertRemoteService { + /** @param ctx - Host context where a credential provider may be mounted. */ + constructor(ctx: Context) { + super(ctx, 'credentialsController', { namespace: 'credentials' }) + } + + /** + * Describe several references for one configuration surface. Batched because + * a settings page describes every reference its rows name at once, and one + * round trip keeps those rows from settling separately. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`. + * @returns one view per requested name, keyed by that name. + * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted. + */ + @Remote + async describe(refs: string[]): Promise> { + const request = parseRequest('credentials.describe', describeRequestSchema, { refs }) + const branded = request.refs.map(ref => [ref, credentialRef(ref)] as const) + const credentials = this.provider() + const entries = await Promise.all(branded.map(async ([ref, key]) => + [ref, projectCredentialInfo(await credentials.describe(key))] as const)) + return Object.fromEntries(entries) + } + + /** + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. + * @param ref - reference name to store under. + * @param value - the non-empty secret value. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async set(ref: string, value: string): Promise { + const request = parseRequest('credentials.set', setRequestSchema, { ref, value }) + const branded = credentialRef(request.ref) + const credentials = this.provider() + await this.write(request.ref, () => credentials.set(branded, request.value)) + } + + /** + * Remove one reference from a configuration surface. + * @param ref - reference name to remove. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async unset(ref: string): Promise { + const request = parseRequest('credentials.unset', unsetRequestSchema, { ref }) + const branded = credentialRef(request.ref) + const credentials = this.provider() + await this.write(request.ref, () => credentials.unset(branded)) + } + + /** Resolve the optional provider or report how to supply it. */ + private provider(): CredentialProvider { + const credentials = this.ctx.get('credentials') + if (credentials === undefined) { + throw new TypertRemoteFailure({ + code: 'internal', + message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', + details: {}, + }) + } + return credentials + } + + /** + * Run one remote write and report every refusal as `credential-rejected` + * carrying the seam's own message: a read-only source shadowing the reference + * is what a configuration surface must show verbatim. Callers brand the + * reference before entering, so a name outside the grammar never reaches this + * path and fails the same way it does on the read side. The details name only + * the reference, so no failure path can carry the value back out. + */ + private async write(ref: string, write: () => Promise): Promise { + try { + await write() + } catch (error: unknown) { + throw new TypertRemoteFailure({ + code: 'credential-rejected', + message: error instanceof Error ? error.message : String(error), + details: { ref }, + }) + } + } +} + +export default CredentialsController diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts new file mode 100644 index 0000000000..e893d06649 --- /dev/null +++ b/packages/api/settings-controller/src/index.ts @@ -0,0 +1,222 @@ +/** + * Host Remote owner for the configuration surfaces over the settings-domain + * seams. Two namespaces: `settings`, the redacted reads and writes of + * `ctx.settings`, owned by the class below; and `credentials`, mounted from + * here as its own plugin. + * + * @module @deepseek-ai/dsh-api-settings-controller + */ + +import { Context } from '@deepseek-ai/cordis' +import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' +import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings' +import type { + SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-settings/types' +import type { JsonValue } from '@deepseek-ai/dsh-session/types' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { z } from 'zod' +import { CredentialsController } from './credentials.ts' + +export { CredentialsController } from './credentials.ts' +export type * from './types.ts' + +const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }) + +/** + * Project one redacted descriptor onto its wire view, field by field. The + * Gateway returns a business result without decoding it, so a provider whose + * descriptor carried extra enumerable properties would otherwise serialize them + * to the caller. + * @param descriptor - one descriptor read under `redactSecrets`. + * @returns the same facts with nothing else attached. + */ +function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView { + return { + ns: String(descriptor.ns), + schema: descriptor.schema as JsonValue, + value: descriptor.value as JsonValue, + ...descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue }, + ...descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue }, + applies: descriptor.applies, + secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })), + revision: descriptor.revision, + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host owner of the `settings` Remote namespace. */ + settingsController: SettingsController + } +} + +/** + * Host service backing the generated `ctx.remote.settings` namespace. Every + * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot + * ride a response. Writes expose the settings service's merge, replacement, + * and path-addressed operations, and classify every provider refusal as + * `settings-conflict` or `settings-rejected` with the service's message. + */ +export class SettingsController extends TypertRemoteService { + /** + * Register the settings namespace and mount the credentials namespace beside + * it. Both namespaces stay registered when a provider is absent so calls can + * return the configuration API's actionable missing-provider diagnostic. + * @param ctx - Host context where settings and credential providers may be mounted. + */ + constructor(ctx: Context) { + super(ctx, 'settingsController', { namespace: 'settings' }) + ctx.plugin(CredentialsController) + } + + /** + * Describe every registered namespace for a configuration page: redacted + * layered values plus the serialized schema the page renders its form from. + * @returns provider writability, local-document presence, and one view per namespace. + * @throws TypertRemoteFailure when no settings provider is mounted. + */ + @Remote + describe(): SettingsDescribeValue { + const settings = this.provider() + return { + writable: settings.writable, + hasDocument: settings.documentPath !== undefined, + namespaces: settings.describe({ redactSecrets: true }).map(namespaceView), + } + } + + /** + * Merge a patch into one namespace's stored user section. + * @param ns - namespace key to write. + * @param patch - fields to merge into the user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + update( + ns: string, + patch: Record, + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, 'update', patch, expectedRevision) + } + + /** + * Replace one namespace's stored user section wholesale. + * @param ns - namespace key to write. + * @param section - complete replacement user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + replace( + ns: string, + section: Record, + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, 'replace', section, expectedRevision) + } + + /** + * Apply path-addressed edits to one namespace's user section, resolved against + * the section as stored rather than against whatever the caller last read, + * then answer with that namespace's new redacted view. + * @param ns - namespace key to write. + * @param ops - the edits to apply, in order. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async mutate( + ns: string, + ops: SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, 'mutate', ops, expectedRevision) + } + + private async write( + ns: string, + mode: 'update' | 'replace' | 'mutate', + input: Record | SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise { + const parsed = settingsNamespaceRequestSchema.safeParse({ ns }) + if (!parsed.success) { + throw new TypertRemoteFailure({ + code: 'bad-request', + message: `invalid payload for settings.${mode}`, + details: { issues: parsed.error.issues }, + }) + } + const settings = this.provider() + let branded + try { + // A malformed name can address no registration, so it fails exactly as an + // unregistered one does. + branded = settingsNamespace(parsed.data.ns) + } catch (error: unknown) { + throw rejected(ns, error) + } + try { + if (mode === 'update') await settings.update(branded, input, expectedRevision) + else if (mode === 'replace') await settings.replace(branded, input, expectedRevision) + else await settings.mutate(branded, input as SettingsPathOp[], expectedRevision) + } catch (error: unknown) { + throw rejected(ns, error) + } + const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded) + if (descriptor === undefined) { + // The write committed but the namespace vanished before this read: only a + // concurrent registrant disposal can produce it. + throw new TypertRemoteFailure({ + code: 'internal', + message: `settings namespace "${ns}" was disposed after the ${mode}`, + details: {}, + }) + } + return namespaceView(descriptor) + } + + /** Resolve the optional provider or report how to supply it. */ + private provider(): SettingsProvider { + const settings = this.ctx.get('settings') + if (settings === undefined) { + throw new TypertRemoteFailure({ + code: 'internal', + message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', + details: {}, + }) + } + return settings + } +} + +/** + * Classify one seam refusal. A stale writer is its own outcome, not a malformed + * request: the client must re-read and re-apply rather than treat the write as + * invalid. + * @param ns - the namespace the write addressed. + * @param error - whatever the seam threw. + * @returns the failure to raise for that refusal. + */ +function rejected(ns: string, error: unknown): TypertRemoteFailure { + if (error instanceof SettingsConflictError) { + return new TypertRemoteFailure({ + code: 'settings-conflict', + message: error.message, + details: { ns, expected: error.expected, actual: error.actual }, + }) + } + return new TypertRemoteFailure({ + code: 'settings-rejected', + message: error instanceof Error ? error.message : String(error), + details: { ns }, + }) +} + +export default SettingsController diff --git a/packages/api/settings-controller/src/invariant.ts b/packages/api/settings-controller/src/invariant.ts new file mode 100644 index 0000000000..a4da3cb426 --- /dev/null +++ b/packages/api/settings-controller/src/invariant.ts @@ -0,0 +1,23 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-settings-controller/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-api-settings-controller' + +/** Cordis companion plugin name. */ +export const name = 'api-settings-controller-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the settings and credential seams own storage and + * update events, while this package only projects their methods onto the wire. + */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts new file mode 100644 index 0000000000..81249e3065 --- /dev/null +++ b/packages/api/settings-controller/src/types.ts @@ -0,0 +1,49 @@ +/** + * Browser-safe failure vocabulary of the configuration surfaces this package + * serves. The redacted views themselves live with their seam in + * `@deepseek-ai/dsh-settings/types`, whose Cordis event declarations already + * register that file for the Client compilation face. + * + * @module @deepseek-ai/dsh-api-settings-controller/types + */ + +/** Stable settings failure details returned by the `settings` namespace. */ +export interface SettingsErrorDetailsMap { + /** + * Every seam refusal that is not a stale write: an unregistered or malformed + * namespace, a read-only provider, schema validation, storage. + */ + 'settings-rejected': { readonly ns: string } + /** + * The stored revision moved after the caller read it. Its own outcome rather + * than an invalid request: the caller must re-read and re-apply. + */ + 'settings-conflict': { readonly ns: string; readonly expected: number; readonly actual: number } +} + +/** Settings business failure carried by a rejected Remote call. */ +export type SettingsError = { + [Code in keyof SettingsErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: SettingsErrorDetailsMap[Code] + } +}[keyof SettingsErrorDetailsMap] + +/** Stable credential failure details returned by the `credentials` namespace. */ +export interface CredentialErrorDetailsMap { + /** + * The provider refused a valid write, for example because a read-only source + * shadows the reference. The details name only the reference, never the value. + */ + 'credential-rejected': { readonly ref: string } +} + +/** Credential business failure carried by a rejected Remote call. */ +export type CredentialError = { + [Code in keyof CredentialErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: CredentialErrorDetailsMap[Code] + } +}[keyof CredentialErrorDetailsMap] diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts new file mode 100644 index 0000000000..a27af7687c --- /dev/null +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' +import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import CredentialsController from '../src/credentials.ts' +import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' + +/** A store whose `describe` carries more than the view declares, as a foreign provider might. */ +class LeakyCredentials extends MemoryCredentials { + override describe(): Promise { + return Promise.resolve( + { configured: true, source: 'memory', writable: true, value: 'sk-leaked' } as CredentialInfo, + ) + } +} + +/** A store whose write rejects with a bare string, the way some client libraries do. */ +class LiteralRejectingCredentials extends MemoryCredentials { + override async set(): Promise { + throw 'the store refused' + } +} + +/** A store whose provider-owned policy rejects an otherwise valid write. */ +class RejectingCredentials extends MemoryCredentials { + override set(): Promise { + return Promise.reject(new Error('a read-only source shadows this reference')) + } +} + +async function boot( + seed: Record = {}, + provider: typeof MemoryCredentials = MemoryCredentials, +): Promise { + const ctx = new Context() + await ctx.plugin(provider, seed) + await ctx.plugin(CredentialsController) + return ctx.credentialsController +} + +describe('the credentials Remote namespace a configuration surface calls', () => { + it('publishes the credentials namespace from its own service key', async () => { + const controller = await boot() + const binding = controller.typertRemote + expect(binding.serviceKey).toBe('credentialsController') + expect(binding.namespace).toBe('credentials') + expect(remoteMethods(controller)).toEqual([ + { method: 'describe', invocation: { kind: 'direct' } }, + { method: 'set', invocation: { kind: 'direct' } }, + { method: 'unset', invocation: { kind: 'direct' } }, + ]) + }) + + it('reports the actionable configuration error while no credential provider is mounted', async () => { + const ctx = new Context() + await ctx.plugin(CredentialsController) + for (const call of [ + () => ctx.credentialsController.describe(['DEEPSEEK_API_KEY']), + () => ctx.credentialsController.set('DEEPSEEK_API_KEY', 'sk-live'), + () => ctx.credentialsController.unset('DEEPSEEK_API_KEY'), + ]) { + const failure = await call().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + expect((failure as TypertRemoteFailure).failure).toEqual({ + code: 'internal', + message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', + details: {}, + }) + } + }) + + it('describes a batch of references as one map, values excluded', async () => { + const controller = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' }) + const described = await controller.describe(['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']) + expect(described).toEqual({ + DEEPSEEK_API_KEY: { configured: true, source: 'memory', writable: true }, + OPENAI_API_KEY: { configured: false, writable: true }, + }) + expect(JSON.stringify(described)).not.toContain('sk-seeded') + }) + + it('reports an invalid reference as bad-request', async () => { + const controller = await boot() + for (const call of [ + () => controller.describe(['DEEPSEEK_API_KEY', 'not a var']), + () => controller.set('not a var', 'sk-live'), + () => controller.unset('not a var'), + ]) { + const failure = await call().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + } + }) + + it('answers the largest batch it accepts and reports one reference more as bad-request', async () => { + const controller = await boot() + const accepted = Array.from({ length: 64 }, (_unused, index) => `REF_${String(index)}`) + expect(Object.keys(await controller.describe(accepted))).toHaveLength(64) + const failure = await controller.describe([...accepted, 'REF_64']).catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + }) + + it('answers only the fields the view declares, whatever a provider returns', async () => { + const controller = await boot({}, LeakyCredentials) + const described = await controller.describe(['DEEPSEEK_API_KEY']) + expect(described.DEEPSEEK_API_KEY).toEqual({ configured: true, source: 'memory', writable: true }) + expect(JSON.stringify(described)).not.toContain('sk-leaked') + }) + + it('stores and removes through the same references the batch describes', async () => { + const controller = await boot() + await controller.set('DEEPSEEK_API_KEY', 'sk-live') + expect(await controller.describe(['DEEPSEEK_API_KEY'])) + .toEqual({ DEEPSEEK_API_KEY: { configured: true, source: 'memory', writable: true } }) + await controller.unset('DEEPSEEK_API_KEY') + expect(await controller.describe(['DEEPSEEK_API_KEY'])) + .toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } }) + }) + + it('reports a refused write as credential-rejected naming only the reference', async () => { + const controller = await boot({}, RejectingCredentials) + const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + const { code, message, details } = (failure as TypertRemoteFailure).failure + expect(code).toBe('credential-rejected') + expect(message).toContain('read-only source') + expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' }) + }) + + it('reports an empty value as bad-request', async () => { + const controller = await boot() + const failure = await controller.set('DEEPSEEK_API_KEY', '').catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + }) + + it('stringifies a refusal that is not an Error', async () => { + const controller = await boot({}, LiteralRejectingCredentials) + const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure.message).toBe('the store refused') + }) +}) diff --git a/packages/api/settings-controller/tests/invariant.spec.ts b/packages/api/settings-controller/tests/invariant.spec.ts new file mode 100644 index 0000000000..6454249191 --- /dev/null +++ b/packages/api/settings-controller/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import * as SettingsControllerInvariant from '../src/invariant.ts' + +describe('api-settings-controller invariant companion', () => { + it('reserves the package name against duplicate registration', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry) + await ctx.plugin(SettingsControllerInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-api-settings-controller', () => {}) + }).toThrow(/already registered/) + }) +}) diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts new file mode 100644 index 0000000000..ed6e9325fd --- /dev/null +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import SettingsController from '../src/index.ts' +import { MemorySettings } from '../../../settings/settings/tests/memory.ts' + +const NS = settingsNamespace('ui-test') + +const Profile = z.object({ + preference: z.union(['light', 'dark']).default('light'), + apiKey: z.string().role('secret'), +}) + +/** A provider that reports a local document, for the `hasDocument` fact. */ +class DocumentSettings extends MemorySettings { + override get documentPath(): string | undefined { + return '/deployment/settings.yaml' + } +} + +/** A provider whose read forgets the namespace its write just committed. */ +class VanishingSettings extends MemorySettings { + override describe(): SettingsDescriptor[] { + return [] + } +} + +/** + * A provider whose descriptor omits the secret-slot list. `secrets` is optional + * on the descriptor, so a foreign provider may leave it out even under + * `redactSecrets`, and the view still has to declare an empty list. + */ +class SlotlessSettings extends MemorySettings { + override describe(): SettingsDescriptor[] { + return [{ + ns: NS, + schema: Profile.toJSON(), + value: { preference: 'light' }, + applies: 'live', + revision: 0, + } as unknown as SettingsDescriptor] + } +} + +/** A provider that refuses every write the way a read-only backing store would. */ +class RefusingSettings extends MemorySettings { + override mutate(ns: SettingsNamespace): Promise { + return Promise.reject(new Error(`settings "${ns}" is read-only in this deployment`)) + } +} + +/** A provider that refuses with a bare string, the way some storage clients do. */ +class LiteralRefusingSettings extends MemorySettings { + override async mutate(): Promise { + throw 'the document is locked' + } +} + +async function boot( + provider: typeof MemorySettings = MemorySettings, + options: { doc?: Record; base?: { preference: 'light' | 'dark' } } = {}, +): Promise<{ controller: SettingsController; ctx: Context }> { + const ctx = new Context() + await ctx.plugin(provider, options.doc === undefined ? {} : { doc: options.doc }) + ctx.settings.register(NS, Profile, options.base === undefined ? {} : { base: options.base }) + await ctx.plugin(SettingsController) + return { controller: ctx.settingsController, ctx } +} + +describe('the settings Remote namespace a configuration page calls', () => { + it('publishes the settings namespace from its own service key', async () => { + const { controller } = await boot() + expect(controller.typertRemote.serviceKey).toBe('settingsController') + expect(controller.typertRemote.namespace).toBe('settings') + expect(remoteMethods(controller)).toEqual([ + { method: 'describe', invocation: { kind: 'direct' } }, + { method: 'update', invocation: { kind: 'direct' } }, + { method: 'replace', invocation: { kind: 'direct' } }, + { method: 'mutate', invocation: { kind: 'direct' } }, + ]) + }) + + it('reports the actionable configuration error while no settings provider is mounted', async () => { + const ctx = new Context() + await ctx.plugin(SettingsController) + for (const call of [ + () => ctx.settingsController.describe(), + () => ctx.settingsController.update('ui-test', {}, undefined), + () => ctx.settingsController.replace('ui-test', {}, undefined), + () => ctx.settingsController.mutate('ui-test', [], undefined), + ]) { + const failure = await Promise.resolve().then(call).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + expect((failure as TypertRemoteFailure).failure).toEqual({ + code: 'internal', + message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', + details: {}, + }) + } + }) + + it('mounts the credentials namespace beside its own', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings) + ctx.settings.register(NS, Profile) + const fiber = ctx.plugin(SettingsController) + await fiber.await() + expect(ctx.get('credentialsController')).toBeDefined() + await fiber.dispose() + expect(ctx.get('settingsController')).toBeUndefined() + expect(ctx.get('credentialsController')).toBeUndefined() + }) + + it('describes every namespace redacted, with the deployment facts around them', async () => { + const { controller } = await boot(DocumentSettings, { doc: { 'ui-test': { apiKey: 'sk-stored' } } }) + const value = controller.describe() + expect(value).toMatchObject({ writable: true, hasDocument: true }) + const [view] = value.namespaces + expect(view?.ns).toBe('ui-test') + // The secret never rides; its slot reports only that one is stored. + expect(JSON.stringify(value)).not.toContain('sk-stored') + expect(view?.secrets).toEqual([{ path: ['apiKey'], set: true }]) + // Redaction removes the field rather than replacing it, so the layer that + // stored a secret comes back empty instead of carrying a placeholder. + expect(view?.user).toEqual({}) + }) + + it('reports a read-only provider and omits the layers it has none of', async () => { + const { controller } = await boot(class extends MemorySettings { + override get writable(): boolean { + return false + } + }) + const value = controller.describe() + expect(value).toMatchObject({ writable: false, hasDocument: false }) + const [view] = value.namespaces + // No composition base was declared and no user section is stored, so + // neither optional layer appears at all. + expect(view && 'base' in view).toBe(false) + expect(view && 'user' in view).toBe(false) + }) + + it('declares an empty slot list when the provider names no secrets', async () => { + const { controller } = await boot(SlotlessSettings) + const [view] = controller.describe().namespaces + expect(view?.secrets).toEqual([]) + }) + + it('carries the composition base layer when the registrant declared one', async () => { + const { controller } = await boot(MemorySettings, { base: { preference: 'dark' } }) + const [view] = controller.describe().namespaces + expect(view?.base).toEqual({ preference: 'dark' }) + }) + + it('applies path-addressed edits and answers with the namespace it just wrote', async () => { + const { controller } = await boot() + const view = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined) + expect(view).toMatchObject({ ns: 'ui-test', user: { preference: 'dark' } }) + expect(view.revision).toBeGreaterThan(0) + }) + + it('supports merge updates and wholesale replacement on the Remote namespace', async () => { + const { controller } = await boot(MemorySettings, { + doc: { 'ui-test': { preference: 'dark', apiKey: 'sk-stored' } }, + }) + const updated = await controller.update('ui-test', { preference: 'light' }, undefined) + expect(updated.user).toEqual({ preference: 'light' }) + expect(updated.secrets).toEqual([{ path: ['apiKey'], set: true }]) + + const replaced = await controller.replace('ui-test', {}, updated.revision) + expect(replaced.value).toEqual({ preference: 'light' }) + expect(replaced.user).toEqual({}) + expect(replaced.secrets).toEqual([{ path: ['apiKey'], set: false }]) + }) + + it('refuses a stale write as settings-conflict carrying both revisions', async () => { + const { controller } = await boot() + const held = controller.describe().namespaces[0]!.revision + await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], held) + const failure = await controller + .mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'light' }], held) + .catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + const { code, details } = (failure as TypertRemoteFailure).failure + expect(code).toBe('settings-conflict') + expect(details).toMatchObject({ ns: 'ui-test', expected: held }) + }) + + it('answers a malformed namespace exactly as an unregistered one', async () => { + const { controller } = await boot() + for (const ns of ['Not A Namespace', 'unregistered']) { + const failure = await controller.mutate(ns, [{ op: 'unset', path: ['preference'] }], undefined) + .catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ + code: 'settings-rejected', + details: { ns }, + }) + } + }) + + it('reports an empty namespace as bad-request', async () => { + const { controller } = await boot() + for (const call of [ + () => controller.update('', {}, undefined), + () => controller.replace('', {}, undefined), + () => controller.mutate('', [], undefined), + ]) { + const failure = await call().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + } + }) + + it('reports a refused write as settings-rejected carrying the seam message', async () => { + const { controller } = await boot(RefusingSettings) + const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined) + .catch((error: unknown) => error) + const { code, message } = (failure as TypertRemoteFailure).failure + expect(code).toBe('settings-rejected') + expect(message).toContain('read-only in this deployment') + }) + + it('stringifies a refusal that is not an Error', async () => { + const { controller } = await boot(LiteralRefusingSettings) + const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined) + .catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure.message).toBe('the document is locked') + }) + + it('reports a namespace disposed between the write and its read-back', async () => { + const { controller } = await boot(VanishingSettings) + const failure = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined) + .catch((error: unknown) => error) + const { code, message } = (failure as TypertRemoteFailure).failure + expect(code).toBe('internal') + expect(message).toContain('was disposed after the mutate') + }) +}) diff --git a/packages/api/settings-controller/tsconfig.json b/packages/api/settings-controller/tsconfig.json new file mode 100644 index 0000000000..5b854dbe64 --- /dev/null +++ b/packages/api/settings-controller/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../core/session" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../settings/settings" + }, + { + "path": "../../typert/protocol" + } + ] +} diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 9e4f71923f..3f889dd8b4 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -86,6 +86,11 @@ - id: session-controller name: '@deepseek-ai/dsh-api-session-controller' + # Configuration-surface reads and writes over Typert Remote. Each method + # reports an actionable error when its settings-domain provider is absent. + - id: settings-controller + name: '@deepseek-ai/dsh-api-settings-controller' + # Workspace commands and reconnect-safe projection over Typert Remote. - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index dfd0b68de3..0f216073de 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -108,6 +108,7 @@ "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index 9d80f4ae79..8ddcd99883 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -9,9 +9,11 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import type { CredentialKey, CredentialRecord, CredentialRef } from './types.ts' +import type { CredentialInfo, CredentialKey, CredentialRecord, CredentialRef } from './types.ts' -export type { ApiKeyRecord, CredentialKey, CredentialRecord, CredentialRef, GrantRecord } from './types.ts' +export type { + ApiKeyRecord, CredentialInfo, CredentialKey, CredentialRecord, CredentialRef, GrantRecord, +} from './types.ts' const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ @@ -119,16 +121,6 @@ export interface ResolvedCredential { source: string } -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ -export interface CredentialInfo { - /** Whether {@link CredentialProvider.resolve} would currently return a value. */ - configured: boolean - /** Source layer currently supplying the value; absent while unconfigured. */ - source?: string - /** Whether {@link CredentialProvider.set} would currently succeed for this reference. */ - writable: boolean -} - /** Presence and writability facts for one record, safe for configuration UIs — never the value. */ export interface CredentialRecordInfo { /** diff --git a/packages/credentials/credentials/src/types.ts b/packages/credentials/credentials/src/types.ts index 2a76ae5c61..57e0f99706 100644 --- a/packages/credentials/credentials/src/types.ts +++ b/packages/credentials/credentials/src/types.ts @@ -1,8 +1,9 @@ /** * Client-safe type surface of the credential seam: the two key brands, the - * stored-record union, and the seam's Cordis event declarations. Types only — - * no runtime code, and nothing here reaches a Host-only symbol, so a Client - * compilation face reads exactly the signature the Host emits. + * stored-record union, the reference view crossing the Remote wire, and the + * seam's Cordis event declarations. Types only — no runtime code, and nothing + * here reaches a Host-only symbol, so a Client compilation face reads exactly + * the signature the Host emits. * * @module @deepseek-ai/dsh-credentials/types */ @@ -58,6 +59,20 @@ export interface GrantRecord { /** One durable credential record, tagged by what the seam may do with it. */ export type CredentialRecord = ApiKeyRecord | GrantRecord +/** + * Source and writability facts for one reference, safe for configuration UIs — + * never the value. The view has no slot a value could ride in, which is what + * lets the whole read half cross the Remote wire. + */ +export interface CredentialInfo { + /** Whether resolving the reference would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether the active provider can write this reference. */ + writable: boolean +} + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 5d3f0b8838..9316515b1b 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -37,15 +37,17 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/settings/settings/src/types.ts b/packages/settings/settings/src/types.ts index 0b3e3b0e10..5c00b877eb 100644 --- a/packages/settings/settings/src/types.ts +++ b/packages/settings/settings/src/types.ts @@ -1,13 +1,15 @@ /** * Client-safe type surface of the user-settings seam: the namespace brand, the - * commit-origin union, and the seam's Cordis event declarations. Types only — - * no runtime code, and nothing here reaches a Host-only symbol, so a Client + * commit-origin union, the redacted views a configuration surface reads over + * the Remote wire, and the seam's Cordis event declarations. Types only — no + * runtime code, and nothing here reaches a Host-only symbol, so a Client * compilation face reads exactly the signatures the Host emits. * * @module @deepseek-ai/dsh-settings/types */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { JsonValue } from '@deepseek-ai/dsh-session/types' /** Nominal id of one registered settings namespace. */ export type SettingsNamespace = Branded<'SettingsNamespace'> @@ -15,6 +17,61 @@ export type SettingsNamespace = Branded<'SettingsNamespace'> /** Origin of one committed settings change. */ export type SettingsUpdateSource = 'update' | 'provider' +/** One schema-declared secret slot inside a redacted namespace value. */ +export interface SettingsSecretView { + /** Path from the section root to the removed field. */ + path: string[] + /** Whether the slot currently holds a value; the value itself never rides. */ + set: boolean +} + +/** + * Wire view of one registered namespace, always read under `redactSecrets`. The + * JSON-valued fields are `JsonValue` rather than the descriptor's `unknown` + * because the Remote boundary admits no unconstrained data. + */ +export interface SettingsNamespaceView { + /** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */ + ns: string + /** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */ + schema: JsonValue + /** Redacted resolved value (schema defaults → composition base → user layer). */ + value: JsonValue + /** Redacted composition base layer, when the registrant declared one. */ + base?: JsonValue + /** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */ + user?: JsonValue + /** When the owner applies changes. */ + applies: 'live' | 'restart' + /** Every schema-declared secret slot with its configured state. */ + secrets: SettingsSecretView[] + /** + * Monotonic revision of the raw user section this view was read at. Send it + * back as `expectedRevision` on a write so a stale editor is refused rather + * than silently overwriting a concurrent change. + */ + revision: number +} + +/** + * One path-addressed edit carried by a remote settings write. `set` writes the + * value at the path, creating intermediate objects; `unset` removes it. The + * empty path addresses the section root. + */ +export type SettingsPathOpView = + | { op: 'set'; path: string[]; value: JsonValue } + | { op: 'unset'; path: string[] } + +/** Every registered namespace with the deployment facts a configuration page renders around them. */ +export interface SettingsDescribeValue { + /** Whether the provider accepts writes; `false` disables every write control. */ + writable: boolean + /** Whether a file-backed provider owns a local document, without exposing its Host path. */ + hasDocument: boolean + /** One view per registered namespace. */ + namespaces: SettingsNamespaceView[] +} + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/settings/settings/tsconfig.json b/packages/settings/settings/tsconfig.json index 916ba5a4b9..60ca039d1c 100644 --- a/packages/settings/settings/tsconfig.json +++ b/packages/settings/settings/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../util/brand" }, + { + "path": "../../core/session" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11f1d8f5d3..f271832de3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -683,6 +683,9 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../session-controller + '@deepseek-ai/dsh-api-settings-controller': + specifier: workspace:^ + version: link:../settings-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../workspace-controller @@ -820,6 +823,31 @@ importers: specifier: workspace:^ version: link:../../workspace/workspace + packages/api/settings-controller: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/api/workspace-controller: dependencies: zod: @@ -1376,6 +1404,9 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller + '@deepseek-ai/dsh-api-settings-controller': + specifier: workspace:^ + version: link:../../api/settings-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../../api/workspace-controller @@ -1630,6 +1661,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo @@ -2644,9 +2678,6 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3096,9 +3127,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -5703,9 +5731,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-credentials': - specifier: workspace:^ - version: link:../../credentials/credentials '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker @@ -5752,6 +5777,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -7531,6 +7559,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session packages/settings/settings-file: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 3d789bb894..3332b24350 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -70,6 +70,8 @@ export const SERVICE_PAGE: Record = { cordisInspect: 'extensions.md', authorization: 'credentials.md', credentials: 'credentials.md', + credentialsController: 'credentials.md', + settingsController: 'settings.md', directoryPicker: 'workspace.md', deepseekLlmApiExtensions: 'llm-streaming.md', dynamicCordisRunner: 'extensions.md', @@ -526,6 +528,10 @@ export const LINK_MAP: Readonly> = { SettingsRegisterOptions: 'settings.md', SettingsScope: 'settings.md', SettingsDescriptor: 'settings.md', + SettingsDescribeValue: 'settings.md', + SettingsNamespaceView: 'settings.md', + SettingsPathOpView: 'settings.md', + SettingsSecretView: 'settings.md', SettingsPathOp: 'settings.md', SettingsDescribeOptions: 'settings.md', SettingsUpdateSource: 'settings.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 22ded5918c..00af95b674 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -157,6 +157,20 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['host-apiproxy'], note: 'Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains.', }, + { + key: 'credentialsController', + pkg: 'api-settings-controller', + title: 'Host credential-surface Remote controller', + mode: 'core', + note: 'Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition.', + }, + { + key: 'settingsController', + pkg: 'api-settings-controller', + title: 'Host settings-surface Remote controller', + mode: 'core', + note: 'Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition.', + }, { key: 'workspaceController', pkg: 'api-workspace-controller', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 60ee4b020d..b54672eb87 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1664,7 +1664,7 @@ { "doc": "docs/subsystems/credentials.md", "symbol": "CredentialInfo", - "source": "packages/credentials/credentials/src/index.ts" + "source": "packages/credentials/credentials/src/types.ts" }, { "doc": "docs/subsystems/settings.md", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 7a7e0e9b73..9e88355ec9 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -162,6 +162,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, 'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' }, + 'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' }, 'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' }, 'packages/typert/protocol': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers nothing model-facing.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index f591eaed91..83c2432053 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -52,6 +52,9 @@ "@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"], "@deepseek-ai/dsh-api-session-controller/types": ["./packages/api/session-controller/src/types.ts"], "@deepseek-ai/dsh-api-session-controller/remote-events": ["./packages/api/session-controller/src/remote-events.ts"], + "@deepseek-ai/dsh-api-settings-controller": ["./packages/api/settings-controller/src/index.ts"], + "@deepseek-ai/dsh-api-settings-controller/invariant": ["./packages/api/settings-controller/src/invariant.ts"], + "@deepseek-ai/dsh-api-settings-controller/types": ["./packages/api/settings-controller/src/types.ts"], "@deepseek-ai/dsh-api-workspace-controller": ["./packages/api/workspace-controller/src/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/invariant": ["./packages/api/workspace-controller/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index d19ff00848..f5dc588982 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -146,6 +146,7 @@ { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, + { "path": "./packages/api/settings-controller" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, From 5918dd205e431f2f0878e9a7fb4bc62899ba3784 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:01:02 +0800 Subject: [PATCH 12/18] refactor(client): use settings Remote namespaces --- .../tests/onboarding-deepseek-config.e2e.ts | 8 +- apps/web/tests/preview-boot.e2e.ts | 59 ++-- apps/web/tests/startup-rpc-budget.e2e.ts | 4 +- packages/api/remotes/package.json | 2 + packages/api/remotes/src/client/index.ts | 18 +- packages/api/remotes/tsconfig.client.json | 3 + .../tests/fake-api.client.ts | 10 - packages/client/connection/package.json | 14 +- packages/client/connection/src/client/api.ts | 4 +- .../client/connection/src/client/fixture.ts | 146 +++++---- .../client/connection/src/client/index.ts | 4 +- .../connection/tests/fake-api.client.ts | 10 - .../connection/tests/fixture.client.spec.ts | 74 ++++- .../connection/tests/node-half.host.spec.ts | 8 +- .../client/connection/tsconfig.client.json | 6 + .../client/locale/tests/apply.client.spec.ts | 20 +- .../tests/document-language.client.spec.ts | 14 +- .../ui-agent-preset/src/client/index.ts | 9 +- .../src/client/section-store.ts | 3 +- .../src/client/settings-store.ts | 16 +- .../tests/apply.client.spec.ts | 26 +- .../tests/section-store.client.spec.ts | 19 +- .../tests/settings-store.client.spec.ts | 45 +-- .../client/ui-permission-presets/package.json | 3 - .../ui-permission-presets/src/client/index.ts | 9 +- .../src/client/settings-store.ts | 22 +- .../tests/browser-plugin.client.spec.ts | 5 +- .../permission-presets-row.client.spec.tsx | 10 +- .../tests/settings-store.client.spec.ts | 24 +- .../tests/apply.client.spec.ts | 17 +- .../tests/components.client.spec.tsx | 24 +- .../settings-document-store.client.spec.ts | 21 +- .../tests/shell.client.spec.ts | 13 +- .../src/client/CustomProviderCard.tsx | 29 +- .../src/client/DeepSeekOnboardingDialog.tsx | 5 +- .../src/client/ModelsSection.tsx | 22 +- .../src/client/ProviderEditor.tsx | 35 +- .../src/client/SubagentModelSelectionCard.tsx | 19 +- .../ui-settings-models/src/client/index.ts | 22 +- .../ui-settings-models/src/client/store.ts | 37 ++- .../tests/apply.client.spec.ts | 81 ++--- .../tests/components.client.spec.tsx | 301 +++++++++--------- .../tests/onboarding-dialog.client.spec.tsx | 43 ++- .../tests/provider-form.client.spec.tsx | 68 ++-- .../tests/readiness.client.spec.ts | 4 +- .../tests/store.client.spec.ts | 53 +-- .../tests/welcome-notice.client.spec.tsx | 9 +- .../tests/welcome-store.client.spec.ts | 17 +- .../client/ui-settings-plugins/package.json | 3 - .../ui-settings-plugins/src/client/index.ts | 7 +- .../src/client/web-search-card-controller.ts | 21 +- .../tests/apply.client.spec.ts | 36 +-- .../tests/stores.client.spec.ts | 45 ++- .../client/ui-settings/src/client/index.ts | 16 +- .../ui-settings/src/client/settings-mirror.ts | 26 +- .../ui-settings/src/client/settings-scope.ts | 36 ++- .../ui-settings/tests/plugin.client.spec.ts | 10 +- .../tests/settings-mirror.client.spec.ts | 31 +- .../tests/settings-scope.client.spec.ts | 86 ++--- .../ui-theme/tests/apply.client.spec.ts | 20 +- .../tests/workspaces-service.client.spec.ts | 1 - .../test-support/client-runtime/src/index.ts | 2 + .../test-support/client-runtime/src/remote.ts | 27 +- .../client-runtime/src/settings-remote.ts | 85 +++++ .../tests/remote.client.spec.ts | 55 ++++ 65 files changed, 1103 insertions(+), 819 deletions(-) create mode 100644 packages/test-support/client-runtime/src/settings-remote.ts diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 82079543d3..2474825ffd 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -141,7 +141,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup // Regression pin for the reload flash: both steps are satisfied, yet each // must load private facts before deciding not to show. Dialog chrome lives // inside each visible branch, so the deciding window paints and blocks - // nothing. Holding settings.describe widens that window from loopback + // nothing. Holding settings/describe widens that window from loopback // RTT scale to a deterministic hundreds of milliseconds, removing all // timing dependence from the sampler assertions below. // @@ -162,7 +162,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup if (document.getElementById('root')?.inert === true) sightings.push('inert') }, 8) }) - // EVERY settings.describe issued before the release is held — not just + // EVERY settings/describe issued before the release is held — not just // the first — so the pin cannot silently collapse back to loopback // timing if a second boot-time consumer of the join ever appears. let released = false @@ -171,7 +171,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup released = true for (const resolve of heldRoutes.splice(0)) resolve() } - await page.route('**/api/settings.describe', async (route) => { + await page.route('**/api/settings/describe', async (route) => { if (!released) await new Promise((resolve) => { heldRoutes.push(resolve) }) await route.continue() }) @@ -182,7 +182,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.waitForTimeout(600) releaseDescribe() await page.waitForTimeout(400) - await page.unroute('**/api/settings.describe') + await page.unroute('**/api/settings/describe') acknowledgeReloadConnectionLoss(tripwire, warningsBefore) expect(await page.evaluate(() => (window as unknown as { __takeoverSightings: string[] }).__takeoverSightings)).toEqual([]) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index c89df410bc..cf7a720ac8 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -317,17 +317,6 @@ async function bootPreview(origin: string, browser: Browser): Promise { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } interface PreviewApi { skills: { list(payload: { sessionId: string }): Promise> } - settings: { - describe(payload: object): Promise }>> - update(payload: { ns: string; patch: object; expectedRevision: number }): Promise> - } - credentials: { - set(payload: { ref: string; value: string }): Promise> - unset(payload: { ref: string }): Promise> - describe(payload: { refs: string[] }): Promise - }>> - } } interface PreviewTransport { fetch(input: string, init: RequestInit): Promise @@ -373,22 +362,44 @@ async function bootPreview(origin: string, browser: Browser): Promise { if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) } await createDirectory('/dsh/workspace/.agents/skills', 'runtime-created') - const settings = await api.settings.describe({}) - if (!settings.result.ok) throw new Error(`settings.describe failed: ${settings.result.error.message}`) - const shell = settings.result.value.namespaces.find(namespace => namespace.ns === 'shell') - if (shell === undefined) throw new Error('settings.describe omitted the shell namespace') - const updated = await api.settings.update({ ns: 'shell', patch: { timeoutMs: 61_000 }, expectedRevision: shell.revision }) - if (!updated.result.ok) throw new Error(`settings.update failed: ${updated.result.error.message}`) - const stored = await api.credentials.set({ ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' }) - if (!stored.result.ok) throw new Error(`credentials.set failed: ${stored.result.error.message}`) - const credentials = await api.credentials.describe({ refs: ['PREVIEW_TEST_SECRET'] }) - if (!credentials.result.ok) throw new Error(`credentials.describe failed: ${credentials.result.error.message}`) - const removed = await api.credentials.unset({ ref: 'PREVIEW_TEST_SECRET' }) - if (!removed.result.ok) throw new Error(`credentials.unset failed: ${removed.result.error.message}`) + // Settings and credentials both answer over the Remote carrier, so this + // half of the sweep posts the generated endpoints directly like the + // session read above. + const remote = async (endpoint: string, args: object): Promise => { + const answered = await transport.fetch(`/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: `preview-${endpoint.replace('/', '-')}`, + method: endpoint, + payload: { args }, + }), + }) + const body = await answered.json() as Result + if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) + return body.result.value + } + const settings = await remote<{ namespaces: { ns: string; revision: number }[] }>( + 'settings/describe', {}, + ) + const shell = settings.namespaces.find(namespace => namespace.ns === 'shell') + if (shell === undefined) throw new Error('settings/describe omitted the shell namespace') + await remote('settings/update', { + ns: 'shell', + patch: { timeoutMs: 61_000 }, + expectedRevision: shell.revision, + }) + await remote('credentials/set', { ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' }) + const credentials = await remote>( + 'credentials/describe', + { refs: ['PREVIEW_TEST_SECRET'] }, + ) + await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' }) await new Promise((resolve) => { setTimeout(resolve, 250) }) return { skillCount: skills.result.value.skills.length, - credentialConfigured: credentials.result.value.credentials.PREVIEW_TEST_SECRET?.configured, + credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured, } }) expect(exercised.skillCount).toBeGreaterThan(0) diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index 59faec6512..f6655ffbf3 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -1,4 +1,4 @@ -// Cold boot may issue at most two settings.describe calls regardless of client +// Cold boot may issue at most two settings/describe calls regardless of client // plugin count. No model call or replay fixture is involved. import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' @@ -38,7 +38,7 @@ describe('startup RPC budget', () => { // absorbs the first-connection reset wave the budget must include. await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) await page.waitForTimeout(3000) - const describeCount = calls.filter(method => method === 'settings.describe').length + const describeCount = calls.filter(method => method === 'settings/describe').length expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET) }) }) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 836c54e2ab..aef2bcc642 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", @@ -85,6 +86,7 @@ "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 473abfbe88..8638c54b9f 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote' +import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' @@ -18,6 +19,7 @@ export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-agent-presets/remote' export type {} from '@deepseek-ai/dsh-commands/remote' +export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' @@ -53,10 +55,10 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' */ export type { ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - CredentialView, DiscoveredModelView, IApiClient, + DiscoveredModelView, IApiClient, MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, - SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, + SkillEntry, StreamChunk, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -102,6 +104,13 @@ export type { // reason: a Client contribution names what it sends without importing a Host // package, and this assembly is where both planes legitimately meet. export type { JsonValue } from '@deepseek-ai/dsh-session/types' +// Credential state vocabulary for the credentials namespace (values never ride it). +export type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' +// Redacted namespace vocabulary for the settings namespace (secrets never ride +// it). It travels with its seam, whose `./types` the Client face already reads. +export type { + SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, +} from '@deepseek-ai/dsh-settings/types' // Reference-discovery result vocabulary for the fileReferences and // sessionReferenceResolver namespaces. export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' @@ -112,6 +121,8 @@ export type ClientFailure = | import('@deepseek-ai/dsh-client-connection/client').RpcError | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError | import('@deepseek-ai/dsh-api-session-controller/types').SessionError + | import('@deepseek-ai/dsh-api-settings-controller/types').CredentialError + | import('@deepseek-ai/dsh-api-settings-controller/types').SettingsError | import('@deepseek-ai/dsh-subagent/client').SubagentControlError | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError @@ -139,7 +150,8 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, + agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, dynamicRemote, + fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, ]) { diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 9d738aa3f8..56f664d499 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -66,6 +66,9 @@ { "path": "../session-controller/tsconfig.client.json" }, + { + "path": "../settings-controller" + }, { "path": "../workspace-controller/tsconfig.client.json" }, diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 98d7196358..a0eae81529 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -234,17 +234,7 @@ export class FakeApiClient implements IApiClient { } readonly settings: IApiClient['settings'] = { - describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))), openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), - replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), - mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), - } - - readonly credentials: IApiClient['credentials'] = { - describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))), - set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))), - unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))), } readonly llm: IApiClient['llm'] = { diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 395e443ea3..2be6924acf 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -47,29 +47,31 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^" } } diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 113e72212c..4d4826da79 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -11,8 +11,8 @@ export type { SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, - SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, - CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, + SettingsApi, + ConfigurableProviderView, DiscoveredModelView, LlmApi, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index c3bb6b503d..02eb11fe9c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -29,7 +29,9 @@ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' // wire-fabrication boundary (the schema layer's one-cast-point posture). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' +import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' +import type { SettingsDescribeValue, SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { ApiProxy, ClientRequest, @@ -1778,6 +1780,83 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // DeepSeek route so unrelated GUI journeys do not enter first-run setup. ['DEEPSEEK_API_KEY', true], ]) + + /** Canonical fixture implementation of the generated Settings Remote contract. */ + const settingsRemotes = { + // Only the resolved DeepSeek address needed by first-run readiness is + // represented here. Fixture-backed journeys do not open its Models editor; + // real schema-driven forms ride the HTTP transport. + describe(): RpcResult { + return { + ok: true, + value: { + writable: true, + hasDocument: true, + namespaces: [{ + ns: 'llm-deepseek', + schema: {}, + value: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, + applies: 'live', + secrets: [{ path: ['apiKey'], set: false }], + revision: 0, + }], + }, + } + }, + update(ns: string): ConnectionRpcResult { + return { + ok: false, + error: { + code: 'settings-rejected', + message: 'fixture: the minimal readiness settings descriptor is read-only', + details: { ns }, + }, + } + }, + replace(ns: string): ConnectionRpcResult { + return { + ok: false, + error: { + code: 'settings-rejected', + message: 'fixture: the minimal readiness settings descriptor is read-only', + details: { ns }, + }, + } + }, + mutate(ns: string): ConnectionRpcResult { + // A Remote failure code is free-form, unlike the unary error vocabulary. + return { + ok: false, + error: { + code: 'settings-rejected', + message: 'fixture: no settings namespaces are registered', + details: { ns }, + }, + } + }, + } + + const credentialRemotes = { + describe(refs: readonly string[]): RpcResult> { + return { + ok: true, + value: Object.fromEntries(refs.map(ref => [ref, { + configured: fixtureCredentials.has(ref), + ...fixtureCredentials.has(ref) ? { source: 'file' } : {}, + writable: true, + }])), + } + }, + set(ref: string): RpcResult { + fixtureCredentials.set(ref, true) + return { ok: true, value: undefined } + }, + unset(ref: string): RpcResult { + fixtureCredentials.delete(ref) + return { ok: true, value: undefined } + }, + } + /** * Preset compositions the fixture serves. Held as state rather than * constants so the settings editor's save and delete are exercisable: the @@ -3347,55 +3426,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, }, settings: { - // Only the resolved DeepSeek address needed by first-run readiness is - // represented here. Fixture-backed journeys do not open its Models - // editor; real schema-driven forms ride the HTTP transport. - describe: request => ok(request, { - writable: true, - hasDocument: true, - namespaces: [{ - ns: 'llm-deepseek', - schema: {}, - value: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, - applies: 'live', - secrets: [{ path: ['apiKey'], set: false }], - revision: 0, - }], - }), // Native opens are deterministic no-op successes in this fixture, as is host.openPath. openDocument: request => ok(request, { opened: true as const }), - update: request => err(request, { - code: 'settings-rejected', - message: 'fixture: the minimal readiness settings descriptor is read-only', - details: { ns: request.payload.ns }, - }), - replace: request => err(request, { - code: 'settings-rejected', - message: 'fixture: the minimal readiness settings descriptor is read-only', - details: { ns: request.payload.ns }, - }), - mutate: request => err(request, { - code: 'settings-rejected', - message: 'fixture: no settings namespaces are registered', - details: { ns: request.payload.ns }, - }), - }, - credentials: { - describe: request => ok(request, { - credentials: Object.fromEntries(request.payload.refs.map(ref => [ref, { - configured: fixtureCredentials.has(ref), - ...fixtureCredentials.has(ref) ? { source: 'file' } : {}, - writable: true, - }])), - }), - set: (request) => { - fixtureCredentials.set(request.payload.ref, true) - return ok(request, {}) - }, - unset: (request) => { - fixtureCredentials.delete(request.payload.ref) - return ok(request, {}) - }, }, llm: { providers: request => ok(request, { @@ -3442,7 +3474,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { path?: string name?: string images?: readonly unknown[] - ref?: { id: string; revision: number } + // A goal ref and a credential reference name share this wire field name. + ref?: string | { id: string; revision: number } + refs?: readonly string[] + value?: string + ns?: string agentPreset?: string from?: string id?: string @@ -3493,6 +3529,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, }) case 'subagents/interruptByParent': return Promise.resolve({ ok: true, value: { accepted: true } }) + case 'credentials/describe': return Promise.resolve(credentialRemotes.describe(args.refs ?? [])) + case 'credentials/set': return Promise.resolve(credentialRemotes.set(args.ref as string)) + case 'credentials/unset': return Promise.resolve(credentialRemotes.unset(args.ref as string)) + case 'settings/describe': return Promise.resolve(settingsRemotes.describe()) + case 'settings/update': return Promise.resolve(settingsRemotes.update(args.ns as string)) + case 'settings/replace': return Promise.resolve(settingsRemotes.replace(args.ns as string)) + case 'settings/mutate': return Promise.resolve(settingsRemotes.mutate(args.ns as string)) case 'session/list': return sessionApi.list( args._request as Parameters[0], ) @@ -3619,14 +3662,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) - case 'settings.describe': return this.api.settings.describe(request) case 'settings.openDocument': return this.api.settings.openDocument(request, signal) - case 'settings.update': return this.api.settings.update(request) - case 'settings.replace': return this.api.settings.replace(request) - case 'settings.mutate': return this.api.settings.mutate(request) - case 'credentials.describe': return this.api.credentials.describe(request) - case 'credentials.set': return this.api.credentials.set(request) - case 'credentials.unset': return this.api.credentials.unset(request) case 'llm.providers': return this.api.llm.providers(request) case 'llm.models': return this.api.llm.models(request) case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 8136254f1c..c8b66fbc77 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -37,8 +37,8 @@ export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, RpcMessage, HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, - SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, - CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, + SettingsApi, + ConfigurableProviderView, DiscoveredModelView, LlmApi, } from './api.ts' export { RpcId, diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index c4bd0d03ff..a2cb5f78b8 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -76,17 +76,7 @@ export class FakeApiClient implements IApiClient { } readonly settings: IApiClient['settings'] = { - describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))), openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), - replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), - mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), - } - - readonly credentials: IApiClient['credentials'] = { - describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))), - set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))), - unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))), } readonly llm: IApiClient['llm'] = { diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index b69d775510..de8cbf42d9 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -297,6 +297,8 @@ type FixtureTestApi = ReturnType['api'] & { readonly sessionRemote: FixtureSessionRemote readonly workspace: FixtureWorkspaceApi readonly workspaceRemote: FixtureWorkspaceRemote + readonly credentialRemote: FixtureCredentialRemote + readonly settingsRemote: FixtureSettingsRemote readonly remoteEvents: (signal: AbortSignal) => FixtureRemoteEventStream readonly answerRemoteEvent: (result: FixtureRemoteEventResult) => Promise } @@ -318,12 +320,48 @@ function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { sessionRemote: createSessionRemote(rpc), workspace: createWorkspaceApi(rpc), workspaceRemote: createWorkspaceRemote(rpc), + credentialRemote: createCredentialRemote(rpc), + settingsRemote: createSettingsRemote(rpc), remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal), answerRemoteEvent: (result: FixtureRemoteEventResult) => rpc.call('/api', '$events/result', { args: result }), }) } +/** The fixture's Credentials Remote endpoints over the shared RPC carrier. */ +interface FixtureCredentialRemote { + describe(refs: readonly string[]): Promise> + set(ref: string, value: string): Promise> + unset(ref: string): Promise> +} + +/** The settings Remote reads the fixture serves, addressed like the credential half. */ +interface FixtureSettingsRemote { + describe(): Promise> + update(ns: string, patch: unknown, expectedRevision?: number): Promise> + replace(ns: string, section: unknown, expectedRevision?: number): Promise> +} + +function createSettingsRemote(rpc: ClientConnectionRpc): FixtureSettingsRemote { + return { + describe: () => rpc.call('/api', 'settings/describe', { args: {} }), + update: (ns, patch, expectedRevision) => rpc.call('/api', 'settings/update', { + args: { ns, patch, expectedRevision }, + }), + replace: (ns, section, expectedRevision) => rpc.call('/api', 'settings/replace', { + args: { ns, section, expectedRevision }, + }), + } +} + +function createCredentialRemote(rpc: ClientConnectionRpc): FixtureCredentialRemote { + return { + describe: refs => rpc.call('/api', 'credentials/describe', { args: { refs } }), + set: (ref, value) => rpc.call('/api', 'credentials/set', { args: { ref, value } }), + unset: ref => rpc.call('/api', 'credentials/unset', { args: { ref } }), + } +} + function openFixtureRemoteEvents( rpc: ClientConnectionRpc, signal: AbortSignal, @@ -724,32 +762,40 @@ describe('createFixtureApi', () => { it('serves configured DeepSeek readiness and keeps credential values write-only', async () => { const api = createFixtureApi() - const settings = await api.settings.describe(req({})) - if (!settings.result.ok) throw new Error('settings describe failed') - expect(settings.result.value.namespaces).toMatchObject([{ + const settings = await api.settingsRemote.describe() + if (!settings.ok) throw new Error('settings describe failed') + expect((settings.value as { namespaces: unknown[] }).namespaces).toMatchObject([{ ns: 'llm-deepseek', value: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, secrets: [{ path: ['apiKey'], set: false }], }]) + for (const result of [ + await api.settingsRemote.update('llm-deepseek', {}, undefined), + await api.settingsRemote.replace('llm-deepseek', {}, undefined), + ]) { + expect(result).toMatchObject({ + ok: false, + error: { code: 'settings-rejected', message: 'fixture: the minimal readiness settings descriptor is read-only' }, + }) + } - const initial = await api.credentials.describe(req({ refs: ['DEEPSEEK_API_KEY', 'TEST_API_KEY'] })) - if (!initial.result.ok) throw new Error('credential describe failed') - expect(initial.result.value.credentials).toEqual({ + const describe = async (refs: readonly string[]): Promise> => { + const result = await api.credentialRemote.describe(refs) + if (!result.ok) throw new Error('credential describe failed') + return result.value as Record + } + expect(await describe(['DEEPSEEK_API_KEY', 'TEST_API_KEY'])).toEqual({ DEEPSEEK_API_KEY: { configured: true, source: 'file', writable: true }, TEST_API_KEY: { configured: false, writable: true }, }) - await api.credentials.set(req({ ref: 'TEST_API_KEY', value: 'write-only-fixture-secret' })) - const configured = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] })) - if (!configured.result.ok) throw new Error('credential describe failed') - expect(configured.result.value.credentials.TEST_API_KEY).toEqual({ + await api.credentialRemote.set('TEST_API_KEY', 'write-only-fixture-secret') + expect((await describe(['TEST_API_KEY'])).TEST_API_KEY).toEqual({ configured: true, source: 'file', writable: true, }) - await api.credentials.unset(req({ ref: 'TEST_API_KEY' })) - const cleared = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] })) - if (!cleared.result.ok) throw new Error('credential describe failed') - expect(cleared.result.value.credentials.TEST_API_KEY).toEqual({ configured: false, writable: true }) + await api.credentialRemote.unset('TEST_API_KEY') + expect((await describe(['TEST_API_KEY'])).TEST_API_KEY).toEqual({ configured: false, writable: true }) }) it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => { diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 1cead76b09..4a5121b97b 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -175,7 +175,6 @@ describe('connection node half', () => { const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const methods = [ 'host.openPath', - 'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set', 'llm.discoverModels', 'llm.models', 'agentPreset.openDocument', ] for (const method of methods) { @@ -501,8 +500,7 @@ describe('connection node half over a real HTTP server', () => { const { port, close } = await serve(routes) try { const methods = [ - 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', - 'credentials.describe', 'credentials.set', 'credentials.unset', + 'settings.openDocument', 'host.openPath', 'llm.discoverModels', 'agentPreset.openDocument', @@ -512,7 +510,7 @@ describe('connection node half over a real HTTP server', () => { expect([method, await call(port, method, 'localhost')]).toEqual([method, 401]) expect([method, await call(port, method, 'harness.example')]).toEqual([method, 401]) } - expect(await call(port, 'settings.describe', 'other.example')).toBe(403) + expect(await call(port, 'settings.openDocument', 'other.example')).toBe(403) const declaredCookie = browserCookie(connection, 'harness.example') for (const method of methods) { @@ -521,7 +519,7 @@ describe('connection node half over a real HTTP server', () => { const loopbackAuthority = `127.0.0.1:${String(port)}` expect(await call( port, - 'settings.describe', + 'settings.openDocument', loopbackAuthority, browserCookie(connection, loopbackAuthority), )).toBe(404) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index cb409edde0..6d5533c9b7 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -33,6 +33,12 @@ { "path": "../../core/tools" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../settings/settings" + }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index a63f97269e..71cf197ca3 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -30,22 +30,16 @@ async function bench() { revision, }) const describe = vi.fn(async () => ({ - rpcId: 'locale-describe' as never, - result: { - ok: true as const, - value: { writable: true, hasDocument: true, namespaces: [namespace()] }, - }, + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [namespace()] }, })) - const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { - preference = request.ops[0]!.value + const mutate = vi.fn(async (_ns: string, ops: { value: string }[]) => { + preference = ops[0]!.value revision += 1 - return { - rpcId: 'locale-mutate' as never, - result: { ok: true as const, value: namespace() }, - } + return { ok: true as const, value: namespace() } }) - ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) - const events = new TestRemote(ctx) + ctx.provide('connection', { api: {}, isLoopback: true } as never) + const events = new TestRemote(ctx, { settings: { describe, mutate } }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, events, diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index 89ef9823bb..b75502116e 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -32,17 +32,17 @@ async function bench(preference?: string) { revision, }) const describeRpc = vi.fn(async () => ({ - rpcId: 'locale-describe' as never, - result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [namespace()] } }, + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [namespace()] }, })) - const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { - stored = request.ops[0]!.value + const mutate = vi.fn(async (_ns: string, ops: { value: string }[]) => { + stored = ops[0]!.value revision += 1 - return { rpcId: 'locale-mutate' as never, result: { ok: true as const, value: namespace() } } + return { ok: true as const, value: namespace() } }) - ctx.provide('connection', { api: { settings: { describe: describeRpc, mutate } }, isLoopback: true } as never) + ctx.provide('connection', { api: {}, isLoopback: true } as never) // The settings transport and the forwarded-event port the plugin injects. - new TestRemote(ctx) + new TestRemote(ctx, { settings: { describe: describeRpc, mutate } }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() await ctx.plugin({ inject: [...inject], apply }).await() return { ctx, locale: ctx.get('locale') as LocaleRuntime } diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index b8bcecdcce..dda3c9d26e 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -50,7 +50,9 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope'] +export const inject = [ + 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', +] /** * Mount the General-settings row. @@ -58,11 +60,12 @@ export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.agentP */ export function apply(ctx: ClientContext): void { const { api } = ctx.get('connection') as ConnectionHandle - const controller = new AgentPresetSettingsController(api, ctx.remote, ctx.settingsScope.describe()) + const settingsWire = { settings: ctx.remote.settings } + const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe()) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() - const section = new AgentPresetSectionController(api, ctx.remote, () => { + const section = new AgentPresetSectionController({ ...api, ...settingsWire }, ctx.remote, () => { void controller.load() for (const read of rosterReaders) read() }) diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 0d034cd833..099d92b186 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -15,6 +15,7 @@ */ import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' @@ -133,7 +134,7 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly api: Pick, + private readonly api: SettingsWireFace & Pick, private readonly remote: Pick, /** * Called after this page changes the roster DIRECTORY, so the other diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index cbae273243..398b781e67 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -7,10 +7,10 @@ * namespace's `default` field, which is what the host resolves at creation. */ -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types' -import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsDescribeFace, SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** The agent-preset settings namespace on the host wire. */ export const AGENT_PRESET_SETTINGS_NS = 'agent-presets' @@ -37,18 +37,22 @@ export function messageOf(error: unknown): string { * @returns the failure message, or undefined once the write landed. */ export async function writeDefaultPreset( - api: Pick, + api: SettingsWireFace, id: string, ): Promise { let response try { - response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } }) + response = await api.settings.update( + AGENT_PRESET_SETTINGS_NS, + { default: id }, + undefined, + ) } catch (error) { // The transport rejected rather than answering; the caller must be able to // say so instead of the row silently snapping back. return messageOf(error) } - return response.result.ok ? undefined : response.result.error.message + return response.ok ? undefined : response.error.message } /** One selectable preset. */ @@ -181,7 +185,7 @@ export class AgentPresetSettingsController { * @param describeFace - the shared mirror's describe face (writability source). */ constructor( - private readonly api: Pick, + private readonly api: SettingsWireFace, private readonly remote: Pick, private readonly describeFace: SettingsDescribeFace, ) {} diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 39936daa80..2cde10fc4e 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -70,8 +70,20 @@ async function bench() { const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - const remote = new TestRemote(ctx) const calls: string[] = [] + // The row reads `describe` to learn whether this browser may write at all, + // and its default write is the one op this spec records. + const settings = { + describe: () => Promise.resolve({ + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [] }, + }), + update: (_ns: string, patch: unknown) => { + calls.push(`settings:${JSON.stringify(patch)}`) + return Promise.resolve({ ok: true as const, value: {} }) + }, + } + const remote = new TestRemote(ctx, { settings }) // The roster and the switch are the AgentPresets Remote namespace; the // shared double carries no generated namespaces, so this spec stages its // own. Registered twice on purpose: the nested key satisfies the plugin's @@ -112,14 +124,6 @@ async function bench() { return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } }) }, }, - settings: { - // The row reads this to learn whether this browser may write at all. - describe: () => Promise.resolve({ - rpcId: 'r', - result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }, - }), - update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) }, - }, }, } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() @@ -182,7 +186,9 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope']) + expect(inject).toEqual([ + 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + ]) }) it('registers the General row and the settings section', async () => { diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index ca253c69fa..8d925f4978 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' @@ -65,7 +66,7 @@ const remoteFail = (message: string) => function fakeApi( defaultId: { id: string }, options: FakeOptions = {}, -): Pick { +): SettingsWireFace & Pick { const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } return { host: { @@ -84,15 +85,15 @@ function fakeApi( }, }, settings: { - update: (payload: { ns: string; patch: { default?: string } }) => { - record('settings.update', payload) - if (options.failSettings !== undefined) return fail(options.failSettings) - /* v8 ignore next -- the controller only ever patches `default` */ - defaultId.id = payload.patch.default ?? defaultId.id - return ok({}) + update: (ns: string, patch: { default?: string }) => { + record('settings.update', { ns, patch }) + if (options.failSettings !== undefined) return remoteFail(options.failSettings) + /* v8 ignore next -- the controller only ever sets `default` */ + defaultId.id = patch.default ?? defaultId.id + return remoteOk({}) }, }, - } as unknown as Pick + } as unknown as SettingsWireFace & Pick } /** @@ -579,7 +580,7 @@ describe('deleting', () => { await controller.load() presets.clear() const broken = new AgentPresetSectionController( - { agentPresets: {}, settings: {}, host: {} } as unknown as Pick, + { agentPresets: {}, settings: {}, host: {} } as unknown as SettingsWireFace & Pick, { agentPresets: { list: () => Promise.reject(new Error('gone')), diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 74ef828a35..4d8544dfcf 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -6,7 +6,8 @@ */ import { describe, expect, it } from 'vitest' -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -16,7 +17,7 @@ import { /** The two faces the row reads: the roster Remote and the settings wire. */ interface FakeWire { - api: IApiClient + api: SettingsWireFace remote: Pick } @@ -28,7 +29,7 @@ import { AgentPresetSeatController } from '../src/client/seat-store.ts' type SeatSession = Pick -interface Recorded { ns: string; patch: unknown } +interface Recorded { ns: string; ops: unknown } /** A roster Remote answering a fixed set of rows, or refusing. */ function fakeRoster( @@ -66,26 +67,23 @@ function fakeApi( // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false // and the row disables its control instead of offering a refused write. describe: () => Promise.resolve({ - rpcId: 'r', - result: { - ok: true as const, - value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, - }, + ok: true as const, + value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, }), - update: (payload: { ns: string; patch: unknown }) => { - options.writes?.push({ ns: payload.ns, patch: payload.patch }) + update: (ns: string, patch: { default?: unknown }) => { + options.writes?.push({ ns, ops: patch }) if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith) if (options.failWrite !== undefined) { - return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } }) + return Promise.resolve({ ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } }) } // A committed write moves the roster's default. for (const preset of presets) { - preset.isDefault = preset.id === (payload.patch as { default?: string }).default + preset.isDefault = preset.id === patch.default } - return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) + return Promise.resolve({ ok: true as const, value: {} }) }, }, - } as unknown as IApiClient + } as unknown as SettingsWireFace return { api, remote: fakeRoster(presets, options.failList === undefined ? {} : { failList: options.failList }), @@ -165,7 +163,7 @@ describe('the agent-preset settings controller', () => { it('treats an unavailable optional namespace as an empty roster', async () => { const controller = derivedController({ - api: {} as IApiClient, + api: {} as SettingsWireFace, remote: fakeRoster([], { failList: 'no active Remote method exports this endpoint', failListCode: 'invocation-unavailable', @@ -187,7 +185,10 @@ describe('the agent-preset settings controller', () => { await controller.select('minimal') - expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: 'minimal' } }]) + expect(writes).toEqual([{ + ns: AGENT_PRESET_SETTINGS_NS, + ops: { default: 'minimal' }, + }]) expect(controller.store.getSnapshot().currentValue).toBe('minimal') }) @@ -260,7 +261,7 @@ describe('the agent-preset settings controller', () => { it('reports a transport that rejects rather than answering', async () => { const controller = derivedController({ - api: {} as IApiClient, + api: {} as SettingsWireFace, remote: fakeRoster([], { throwOnList: true }), }) @@ -310,7 +311,7 @@ describe('the new-session chip controller', () => { }, select: (agentId: SessionId, agentPreset: string) => { if (options.throwOn === 'select') return Promise.reject(new Error('socket closed')) - options.writes?.push({ ns: 'select', patch: agentPreset }) + options.writes?.push({ ns: 'select', ops: agentPreset }) return Promise.resolve(options.failSelect === undefined ? { ok: true as const, value: agentPreset } : { @@ -434,7 +435,7 @@ describe('the new-session chip controller', () => { await controller.load() await controller.select('minimal') - expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + expect(writes).toEqual([{ ns: 'select', ops: 'minimal' }]) expect(controller.store.getSnapshot().current).toBe('minimal') }) @@ -453,7 +454,7 @@ describe('the new-session chip controller', () => { // Every later list movement calls apply(); an unspent stage would keep // switching sessions the user never picked for. - expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + expect(writes).toEqual([{ ns: 'select', ops: 'minimal' }]) }) it('drops the stage against a session that already started', async () => { @@ -535,7 +536,7 @@ describe('the new-session chip controller', () => { await controller.select('standard') await first - expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + expect(writes).toEqual([{ ns: 'select', ops: 'minimal' }]) }) it('keeps a staged pick across a roster refresh', async () => { @@ -570,7 +571,7 @@ describe('the new-session chip controller', () => { const controller = derivedController({ // The roster answered; the mirror's read is what failed, so the row // shows the current default without offering a write it never confirmed. - api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as IApiClient, + api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as SettingsWireFace, remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]), }) diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index cc0fc0cfb6..037c6d15bd 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -33,7 +33,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-session-controller", - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-api-remotes", @@ -51,7 +50,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -65,7 +63,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index 404d201039..3b2a405951 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -14,7 +14,6 @@ * created later through the host Settings API. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -44,7 +43,10 @@ export type { } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsScope', 'settingsSchema'] +export const inject = [ + 'commandUi', 'sessions', 'slots', 'locale', 'remote', 'remote.settings', + 'settingsScope', 'settingsSchema', +] const ACCESS_NS = 'permission.access' @@ -113,10 +115,9 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') - const connection = ctx.get('connection') as ConnectionHandle // The shared SettingsScope mirror updates after document commits and reconnects. const controller = new PermissionPresetSettingsController( - ctx.settingsScope.describe(), connection.api, ctx.settingsSchema) + ctx.settingsScope.describe(), { settings: ctx.remote.settings }, ctx.settingsSchema) const load = (): Promise => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 2015cfe0b9..a139df2a7c 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -6,14 +6,12 @@ * back into the mirror. */ -import type { - IApiClient, SettingsNamespaceView, -} from '@deepseek-ai/dsh-api-remotes/client' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' import type { - SchemaNode, SettingsDescribeFace, SettingsSchemaService, + SchemaNode, SettingsDescribeFace, SettingsSchemaService, SettingsWireFace, } from '@deepseek-ai/dsh-client-ui-settings/client' import { displayPermissionPreset } from './presentation.ts' @@ -101,7 +99,7 @@ export class PermissionPresetSettingsController { */ constructor( private readonly describeFace: SettingsDescribeFace, - private readonly api: Pick, + private readonly api: SettingsWireFace, private readonly schema: SettingsSchemaService, ) {} @@ -139,17 +137,17 @@ export class PermissionPresetSettingsController { draft.error = null }) try { - const response = await this.api.settings.mutate({ - ns: PERMISSION_SETTINGS_NS, - ops: [{ op: 'set', path: ['defaultPreset'], value: preset }], - expectedRevision: view.revision, - }) - if (!response.result.ok) throw new Error(response.result.error.message) + const response = await this.api.settings.mutate( + PERMISSION_SETTINGS_NS, + [{ op: 'set', path: ['defaultPreset'], value: preset }], + view.revision, + ) + if (!response.ok) throw new Error(response.error.message) this.saving = false if (this.disposed) return // The mirror publish reaches this row's own subscription, so the fold // is also what republishes the accepted value here. - this.describeFace.acceptView(response.result.value) + this.describeFace.acceptView(response.value) } catch (error) { this.saving = false if (this.disposed) return diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index ef41a39c00..1d43249c75 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client' @@ -40,7 +40,8 @@ async function bench() { const locale = new LocaleRuntime(ctx) locale.setLocale('en') ctx.provide('locale', locale) - const remote = new TestRemote(ctx) + const settingsRemote = scriptedSettingsRemote() + const remote = new TestRemote(ctx, { settings: settingsRemote.settings }) ctx.slots.register({ name: 'root', children: { diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 5ab109883c..de7f6d4411 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -43,8 +43,9 @@ function view(defaultPreset: string, revision = 0): SettingsNamespaceView { } } +/** The settings namespace answers over the Remote carrier, which has no envelope. */ function ok(value: T) { - return { rpcId: 'test', result: { ok: true as const, value } } + return { ok: true as const, value } } const dictionary: Record = en @@ -153,11 +154,8 @@ describe('PermissionRow', () => { settings: { describe: () => describe.promise, mutate: () => Promise.resolve({ - rpcId: 'test', - result: { - ok: false as const, - error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, - }, + ok: false as const, + error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, }), }, }) diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index 8ddc1033ea..b607bcea99 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -35,8 +35,9 @@ function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView } } +/** The settings namespace answers over the Remote carrier, which has no envelope. */ function ok(value: T) { - return { rpcId: 'test', result: { ok: true as const, value } } + return { ok: true as const, value } } /** The permission controller over a real mirror and one fake wire. */ @@ -117,11 +118,11 @@ describe('permission settings store', () => { revision: 4, }) await controller.select('workspace-write') - expect(mutate).toHaveBeenCalledWith({ - ns: 'permission', - ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], - expectedRevision: 4, - }) + expect(mutate).toHaveBeenCalledWith( + 'permission', + [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], + 4, + ) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', currentValue: 'workspace-write', @@ -140,11 +141,8 @@ describe('permission settings store', () => { const failing = permissionController({ describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => Promise.resolve({ - rpcId: 'test', - result: { - ok: false as const, - error: { code: 'settings-conflict', message: 'stale', details: {} }, - }, + ok: false as const, + error: { code: 'settings-conflict', message: 'stale', details: {} }, }), }).controller await failing.load() @@ -171,8 +169,8 @@ describe('permission settings store', () => { const rejected = permissionController({ describe: () => Promise.resolve({ - rpcId: 'test', - result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, + ok: false as const, + error: { code: 'internal', message: 'offline', details: {} }, }), mutate, }).controller diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index d26af3c58a..bf2174d571 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -32,14 +32,11 @@ async function bench(isLoopback = true) { locale.setLocale('zh') ctx.provide('locale', locale) const settingsDescribe = vi.fn(() => Promise.resolve({ - rpcId: 'settings-general' as never, - result: { - ok: true as const, - value: { - writable: true, - hasDocument: true, - namespaces: [], - }, + ok: true as const, + value: { + writable: true, + hasDocument: true, + namespaces: [], }, })) const settingsOpenDocument = vi.fn(() => Promise.resolve({ @@ -47,10 +44,10 @@ async function bench(isLoopback = true) { result: { ok: true as const, value: { opened: true as const } }, })) ctx.provide('connection', { - api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } }, + api: { settings: { openDocument: settingsOpenDocument } }, isLoopback, } as never) - new TestRemote(ctx) + new TestRemote(ctx, { settings: { describe: settingsDescribe } }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } } diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 286a2d52c9..9c2271db4b 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -77,11 +77,8 @@ describe('SettingsDocumentAction', () => { const controller = derivedDocumentStore({ settings: { describe: vi.fn(() => Promise.resolve({ - rpcId: 'document-action' as never, - result: { - ok: true as const, - value: { writable: true, hasDocument: true, namespaces: [] }, - }, + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [] }, })), openDocument, }, @@ -99,14 +96,8 @@ describe('SettingsDocumentAction', () => { it('stays absent without a document and follows a mirror refresh to available', async () => { const describe = vi.fn() - .mockResolvedValueOnce({ - rpcId: 'document-action-absent' as never, - result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }, - }) - .mockResolvedValueOnce({ - rpcId: 'document-action-ready' as never, - result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }, - }) + .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }) + .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }) const wire = { settings: { describe, openDocument: vi.fn() } } as never const mirror = new SettingsDescribeMirror(wire) const controller = new SettingsDocumentStore(wire, mirror) @@ -138,11 +129,8 @@ describe('SettingsDocumentAction', () => { const controller = derivedDocumentStore({ settings: { describe: vi.fn(() => Promise.resolve({ - rpcId: 'document-action' as never, - result: { - ok: true as const, - value: { writable: true, hasDocument: true, namespaces: [] }, - }, + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [] }, })), openDocument: vi.fn(() => Promise.resolve({ rpcId: 'document-open-failed' as never, diff --git a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts index 93b7825ba2..85c55c6ad0 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts +++ b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts @@ -9,18 +9,8 @@ function derivedDocumentStore(api: object) { return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire)) } -function response(hasDocument = false): RpcResponse<{ - writable: boolean - hasDocument: boolean - namespaces: [] -}> { - return { - rpcId: 'settings-document' as never, - result: { - ok: true, - value: { writable: true, hasDocument, namespaces: [] }, - }, - } +function response(hasDocument = false) { + return { ok: true, value: { writable: true, hasDocument, namespaces: [] } } } function opened(): RpcResponse<{ opened: true }> { @@ -30,11 +20,8 @@ function opened(): RpcResponse<{ opened: true }> { } } -function describeFailed(message: string): RpcResponse { - return { - rpcId: 'settings-document-failed' as never, - result: { ok: false, error: { code: 'internal', message, details: {} } }, - } +function describeFailed(message: string) { + return { ok: false as const, error: { code: 'internal', message, details: {} } } } describe('SettingsDocumentStore', () => { diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 1c5b447c62..89da5433ef 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -18,11 +18,14 @@ async function bench() { getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }), subscribe: () => () => {}, } as never) - ctx.provide('connection', { - api: { settings: { describe: async () => ({ result: { ok: false } }) } }, - isLoopback: false, - } as never) - ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('connection', { api: {}, isLoopback: false } as never) + // The shell mounts ui-settings, which injects `remote.settings`; without the + // namespace provided its fiber parks and no slot is ever declared. + const settings = { + describe: async () => ({ ok: false, error: { code: 'internal', message: 'no settings', details: {} } }), + } + ctx.provide('remote', { $on: () => () => {}, settings } as never) + ctx.provide('remote.settings', settings as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry } } diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 836fc8efdc..024173af2b 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -7,7 +7,7 @@ * the provider editor with extra fields: the route id is being *chosen* here, * and the settings address does not exist until it is. One `settings.mutate` * sets the whole profile at `providers.`; the key travels separately - * through `credentials.set` under the reference the profile records, exactly as + * through `credentials/set` under the reference the profile records, exactly as * an existing provider's key does. * * The three fields a hand-declared route cannot default — endpoint, protocol, @@ -23,13 +23,14 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client' import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' import { deriveKeyRef, messageOf } from './store.ts' +import type { ModelsWire } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -59,7 +60,7 @@ export interface CustomProviderCardProps { */ revision: number /** Wire faces for the write and for interrogating the endpoint. */ - api: Pick + api: ModelsWire /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -143,15 +144,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { baseURL, models: models.map(model => ({ ...model })), } - const response = await api.settings.mutate({ - ns: NS, - ops: [{ op: 'set', path: ['providers', route], value: profile }], - // `taken` is a snapshot too, so the id check alone cannot see a route - // declared after this card opened; the revision makes that race a - // `settings-conflict` instead of a write over the other profile. - expectedRevision: openedAt, - }) - if (!response.result.ok) return response.result.error.message + // `taken` is a snapshot too, so the id check alone cannot see a route + // declared after this card opened; the revision makes that race a + // `settings-conflict` instead of a write over the other profile. + const response = await api.settings.mutate( + NS, + [{ op: 'set', path: ['providers', route], value: profile as JsonValue }], + openedAt, + ) + if (!response.ok) return response.error.message // The provider now exists. A retry after the key write below fails must // not re-run this mutate: the revision it holds is the one this write // just superseded, so the Host would answer `settings-conflict` and the @@ -159,10 +160,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { setCommitted(true) } if (storesKey) { - const stored = await api.credentials.set({ ref: keyRef, value: keyValue }) + const stored = await api.credentials.set(keyRef, keyValue) // The profile landed; saying the key did not is the only honest report, // and the retry above now goes straight back to this write. - if (!stored.result.ok) return stored.result.error.message + if (!stored.ok) return stored.error.message } return undefined } diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index a425677489..c27f5379a8 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -8,10 +8,9 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' +import type { ModelsSettingsState, ModelsSettingsStore, ModelsWire } from './store.ts' import { onboardingReadiness } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor } from './ProviderEditor.tsx' @@ -28,7 +27,7 @@ export interface DeepSeekOnboardingInjected { /** Shared Models-page join controller. */ controller: ModelsSettingsStore /** Existing wire face reused by the Models credential editor. */ - api: Pick + api: ModelsWire /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Feature copy. */ diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 6776a097dd..b12fb9fc01 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -14,14 +14,13 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls this package's SlotMap merge (the two Models child slots). import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts' -import type { ModelsSettingsStore, ProviderRow } from './store.ts' +import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' @@ -37,7 +36,7 @@ export interface ModelsSectionInjected { snapshot: ModelsSettingsStore['store'] } /** Wire faces the editor writes through. */ - api: Pick + api: ModelsWire /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Section copy. */ @@ -112,20 +111,21 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): * @returns the failure message, or undefined once the write and reload landed. */ export async function removeProviderProfile( - api: Pick, + api: Pick, controller: ModelsSettingsStore, target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise { try { if (target.credentialRef !== undefined) { - const credential = await api.credentials.unset({ ref: target.credentialRef }) - if (!credential.result.ok) return credential.result.error.message + const credential = await api.credentials.unset(target.credentialRef) + if (!credential.ok) return credential.error.message } - const response = await api.settings.mutate({ - ns: target.settingsNs, - ops: [{ op: 'unset', path: [...target.settingsPath] }], - }) - if (!response.result.ok) return response.result.error.message + const response = await api.settings.mutate( + target.settingsNs, + [{ op: 'unset', path: [...target.settingsPath] }], + undefined, + ) + if (!response.ok) return response.error.message } catch (error) { // The transport rejected rather than answering; the caller must be able // to retry the idempotent operation instead of the row silently staying. diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index eb7a67cd03..675c852125 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -1,7 +1,7 @@ /** * One provider's editor card, hand-written per adapter family: the primary * field is a single write-only **API key** input (the page never asks for an - * environment-variable name — a typed key stores through `credentials.set` + * environment-variable name — a typed key stores through `credentials/set` * under the profile's reference, deriving `_API_KEY` when the profile * has none. The pi-ai profile records that derivation as `apiKeyEnv` only when * a key is entered; a blank key materializes a reference-free profile for @@ -23,7 +23,9 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' +import type { + CredentialInfo, JsonValue, SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-api-remotes/client' import { DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, } from './DeepSeekModelsEditor.tsx' @@ -31,6 +33,7 @@ import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' +import type { ModelsWire } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -64,7 +67,7 @@ export interface ProviderEditorProps { /** Path from the section root to this provider's profile. */ settingsPath: readonly string[] /** Wire faces for writes and for interrogating a provider endpoint. */ - api: Pick + api: ModelsWire /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -117,7 +120,7 @@ export function pathOps( const ops: SettingsPathOpView[] = [] for (const [key, value] of Object.entries(after)) { if (JSON.stringify(previous[key]) === JSON.stringify(value)) continue - ops.push({ op: 'set', path: [...base, key], value }) + ops.push({ op: 'set', path: [...base, key], value: value as JsonValue }) } for (const key of Object.keys(previous)) { if (!(key in after)) ops.push({ op: 'unset', path: [...base, key] }) @@ -155,7 +158,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const { namespace, schema, settingsPath, api, t } = props const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') - const [keyState, setKeyState] = useState(undefined) + const [keyState, setKeyState] = useState(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) // A settings success advances both retry baselines immediately. Keeping the @@ -187,10 +190,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // neither a business rejection nor a transport failure may reach the // browser as an unhandled rejection, so the card simply renders without // the "already configured" hint. - void api.credentials.describe({ refs: [keyRef] }).then( + void api.credentials.describe([keyRef]).then( (response) => { - if (stale || !response.result.ok) return - setKeyState(response.result.value.credentials[keyRef]) + if (stale || !response.ok) return + setKeyState(response.value[keyRef]) }, () => undefined, ) @@ -279,19 +282,19 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ? [{ op: 'set', path: [...settingsPath], value: {} }] : pathOps(settingsPath, committedOriginal, next) if (ops.length > 0) { - const response = await api.settings.mutate({ ns, ops, expectedRevision }) - if (!response.result.ok) { - return response.result.error.code === 'settings-conflict' + const response = await api.settings.mutate(ns, ops, expectedRevision) + if (!response.ok) { + return response.error.code === 'settings-conflict' ? t('conflict') - : response.result.error.message + : response.error.message } - setCommittedOriginal(schema.getPath(response.result.value.user, settingsPath)) - setExpectedRevision(response.result.value.revision) + setCommittedOriginal(schema.getPath(response.value.user, settingsPath)) + setExpectedRevision(response.value.revision) setDraft(next) } if (keyValue.length > 0) { - const stored = await api.credentials.set({ ref: keyRef, value: keyValue }) - if (!stored.result.ok) return stored.result.error.message + const stored = await api.credentials.set(keyRef, keyValue) + if (!stored.ok) return stored.error.message } setKeyDraft('') return undefined diff --git a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx index 3b62f28e60..8f69f41087 100644 --- a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx @@ -2,7 +2,8 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { ModelsSettingsStore } from './store.ts' import type { en } from './locales.ts' import { messageOf } from './store.ts' @@ -15,7 +16,7 @@ export interface SubagentModelSelectionCardProps { /** Whether the settings provider accepts writes. */ writable: boolean /** Settings wire face. */ - api: Pick + api: SettingsWireFace /** Models page controller to refresh after a commit. */ controller: ModelsSettingsStore /** Localized Models copy. */ @@ -45,13 +46,13 @@ export function SubagentModelSelectionCard({ setSaving(true) setSaved(false) setError(undefined) - void api.settings.update({ - ns: namespace.ns, - patch: { enabled: !enabled }, - expectedRevision: namespace.revision, - }).then(async (response) => { - if (!response.result.ok) throw new Error(response.result.error.message) - controller.acceptNamespace(response.result.value) + void api.settings.update( + namespace.ns, + { enabled: !enabled }, + namespace.revision, + ).then(async (response) => { + if (!response.ok) throw new Error(response.error.message) + controller.acceptNamespace(response.value) await controller.load() setSaved(true) }).catch((reason: unknown) => { diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 3633674b15..b975c91f1f 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -24,6 +24,7 @@ import { WelcomeNotice } from './WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts' import { ModelsSettingsStore } from './store.ts' +import type { ModelsWire } from './store.ts' import { createSettingsSchemaOperations } from './schema-operations.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' @@ -41,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'settings.models' -export type { ModelsSettingsState, ProviderRow } from './store.ts' +export type { ModelsCredentials, ModelsSettingsState, ModelsWire, ProviderRow } from './store.ts' /** * Refetch the page snapshot only after its first load: an unopened Models @@ -58,7 +59,10 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registration depends on each slot through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope', 'settingsSchema'] +export const inject = [ + 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.settings', + 'settingsScope', 'settingsSchema', +] /** * Register the Models section once the `settings.section` declaration is on @@ -71,21 +75,29 @@ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle const schema = createSettingsSchemaOperations(ctx.settingsSchema) - const controller = new ModelsSettingsStore(connection.api, schema, ctx.settingsScope.describe()) + // The page's two carriers under one face: model discovery and the catalog + // still ride the unary API, while settings and credentials are Remote + // namespaces. + const wire: ModelsWire = { + ...connection.api, + credentials: ctx.remote.credentials, + settings: ctx.remote.settings, + } + const controller = new ModelsSettingsStore(wire, schema, ctx.settingsScope.describe()) // Registration-time text (the nav label thunk) and the inject faces share // one bound translate; copy freshness rides the locale revision. const t = ctx.locale.bind(NS) as ModelsSectionInjected['t'] const injected = (): ModelsSectionInjected => ({ controller, hooks: { snapshot: controller.store }, - api: connection.api, + api: wire, schema, t, }) const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({ controller, hooks: { models: controller.store }, - api: connection.api, + api: wire, schema, t, }) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index df5bcb5dcd..d3b71ec6ea 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -1,17 +1,17 @@ /** * Models settings page store: one snapshot joining the configurable-provider * directory (`llm.providers`), the settings namespaces (shared settings mirror), - * and the referenced credentials (`credentials.describe`). The host stays the + * and the referenced credentials (`credentials/describe`). The host stays the * single fact source — every mutation writes through the wire and the page * re-renders from the next describe, pushed or refetched. */ import type { - ConfigurableProviderView, CredentialView, IApiClient, SettingsNamespaceView, + ClientRemote, ConfigurableProviderView, CredentialInfo, IApiClient, SettingsNamespaceView, } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsDescribeFace, SettingsRemote } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SettingsSchemaOperations } from './schema-operations.ts' /** @@ -20,6 +20,21 @@ import type { SettingsSchemaOperations } from './schema-operations.ts' */ const PROBE_ROUTE = '\u0000probe' +/** The credentials Remote methods the Models page reads and writes through. */ +export type ModelsCredentials = Pick + +/** + * Every wire face the Models page reaches: the settings and llm unary domains, + * plus the credentials Remote namespace, which is addressed by reference name + * and never answers with a value. + */ +export interface ModelsWire extends Pick { + /** The settings Remote namespace: the redacted read and the profile writes. */ + settings: SettingsRemote + /** Credential state and writes for the references provider profiles name. */ + credentials: ModelsCredentials +} + /** One provider row the page renders. */ export interface ProviderRow { /** The directory entry (route id, display name, settings address, live state). */ @@ -31,14 +46,14 @@ export interface ProviderRow { /** The credential reference the resolved profile names, when one does. */ apiKeyEnv: string | undefined /** Credential state for {@link apiKeyEnv}, once described. */ - credential: CredentialView | undefined + credential: CredentialInfo | undefined /** * Credential state for the page's derived `_API_KEY`, described only * while the profile names no reference — the provider-card seat's * `keyConfigured` fact for dormant and keyless rows, matching the editor's * own derivation rule. */ - derivedCredential?: CredentialView + derivedCredential?: CredentialInfo } /** Page snapshot. */ @@ -122,11 +137,11 @@ export class ModelsSettingsStore { private generation = 0 /** - * @param api - the wire face (credentials/llm domains, and settings writes). + * @param api - the page's wire faces (credentials Remote, llm reads, settings writes). * @param describeFace - the shared mirror's describe face (namespace views and writability). */ constructor( - private readonly api: Pick, + private readonly api: ModelsWire, private readonly schema: SettingsSchemaOperations, private readonly describeFace: SettingsDescribeFace, ) {} @@ -193,16 +208,16 @@ export class ModelsSettingsStore { } }) const refs = [...new Set(rows.map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))] - let credentials: Record = {} + let credentials: Record = {} let credentialError: string | null = null if (refs.length > 0) { try { - const response = await this.api.credentials.describe({ refs }) + const response = await this.api.credentials.describe(refs) // Credential state is an enrichment for the Models page: neither a // business rejection nor a transport failure fails the load. The // onboarding projection below retains the failure distinction. - if (response.result.ok) credentials = response.result.value.credentials - else credentialError = response.result.error.message + if (response.ok) credentials = response.value + else credentialError = response.error.message } catch (error) { credentialError = messageOf(error) } diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index c4d569f77b..7045e4831d 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client' import { @@ -24,14 +24,18 @@ async function bench(isLoopback = true, settings?: object, services: object = {} const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - const remote = new TestRemote(ctx) - // Without a settings face the mirror's reads fail and stay contained; the - // Models join itself never fetches until a section actually loads. The real - // ui-settings apply also provides the settingsSchema service. - ctx.provide('connection', { - api: settings === undefined ? services : { ...services, settings }, - isLoopback, - } as never) + const remote = new TestRemote(ctx, { + credentials: { + describe: vi.fn(() => Promise.resolve({ ok: true, value: {} })), + set: vi.fn(), + unset: vi.fn(), + }, + // Without a settings face the mirror's reads fail and stay contained; the + // Models join itself never fetches until a section actually loads. The real + // ui-settings apply also provides the settingsSchema service. + settings: settings ?? scriptedSettingsRemote().settings, + }) + ctx.provide('connection', { api: services, isLoopback } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, remote } } @@ -51,7 +55,10 @@ function declare(slots: SlotRegistry): () => void { describe('ui-settings-models apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope', 'settingsSchema']) + expect(inject).toEqual([ + 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.settings', + 'settingsScope', 'settingsSchema', + ]) }) it('registers the models nav entry for declarations before or after apply', async () => { @@ -243,21 +250,18 @@ describe('pushed invalidations', () => { const acknowledgement = { current: undefined as string | undefined } const settings = { describe: vi.fn(() => Promise.resolve({ - rpcId: 'apply-welcome' as never, - result: { - ok: true as const, - value: { - writable: true, - hasDocument: false, - namespaces: [{ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - schema: {}, - value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current }, - applies: 'live' as const, - secrets: [], - revision: 0, - }], - }, + ok: true as const, + value: { + writable: true, + hasDocument: false, + namespaces: [{ + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current }, + applies: 'live' as const, + secrets: [], + revision: 0, + }], }, })), } @@ -284,21 +288,18 @@ describe('pushed invalidations', () => { it('joins the refreshed mirror view on a settings invalidation', async () => { let revision = 1 const describe = vi.fn(() => Promise.resolve({ - rpcId: `apply-models-${revision}` as never, - result: { - ok: true as const, - value: { - writable: true, - hasDocument: false, - namespaces: [{ - ns: 'llm-test', - schema: {}, - value: {}, - applies: 'live' as const, - secrets: [], - revision, - }], - }, + ok: true as const, + value: { + writable: true, + hasDocument: false, + namespaces: [{ + ns: 'llm-test', + schema: {}, + value: {}, + applies: 'live' as const, + secrets: [], + revision, + }], }, })) const providers = vi.fn(() => Promise.resolve({ diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 1a72d7c444..512a6573e5 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' @@ -90,7 +90,7 @@ function wireNamespaces(): SettingsNamespaceView[] { return [ { ns: 'llm-deepseek', - schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, + schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', @@ -108,7 +108,7 @@ function wireNamespaces(): SettingsNamespaceView[] { ns: 'llm-plain', schema: JSON.parse(JSON.stringify(Schema.object({ profiles: Schema.dict(Schema.object({ note: Schema.string() })), - }).toJSON())) as unknown, + }).toJSON())) as JsonValue, value: {}, applies: 'live', secrets: [], @@ -116,7 +116,7 @@ function wireNamespaces(): SettingsNamespaceView[] { }, { ns: 'llm-pi-ai', - schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown, + schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as JsonValue, value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } }, applies: 'live', @@ -125,7 +125,7 @@ function wireNamespaces(): SettingsNamespaceView[] { }, { ns: 'subagent-model-selection', - schema: JSON.parse(JSON.stringify(Schema.object({ enabled: Schema.boolean().default(false) }).toJSON())) as unknown, + schema: JSON.parse(JSON.stringify(Schema.object({ enabled: Schema.boolean().default(false) }).toJSON())) as JsonValue, value: { enabled: false }, applies: 'live', secrets: [], @@ -144,20 +144,25 @@ function fail(message: string, code = 'settings-rejected'): RpcResponse { result: { ok: false, error: { code, message, details: { ns: 'x' } } as never }, } } +/** Credentials answers over the Remote carrier, which has no envelope. */ +function remoteOk(value: T) { + return { ok: true as const, value } +} +function remoteFail(message: string, code = 'credential-rejected') { + return { ok: false as const, error: { code, message, details: {} } } +} function scriptedFace(overrides: { update?: ReturnType - replace?: ReturnType mutate?: ReturnType set?: ReturnType unset?: ReturnType } = {}) { const providerNamespace = wireNamespaces().find(view => view.ns === 'llm-pi-ai')! - const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(providerNamespace))) - const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(providerNamespace))) - const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(providerNamespace))) - const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({}))) - const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({}))) + const update = overrides.update ?? vi.fn(() => Promise.resolve(remoteOk(providerNamespace))) + const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(remoteOk(providerNamespace))) + const set = overrides.set ?? vi.fn(() => Promise.resolve(remoteOk(undefined))) + const unset = overrides.unset ?? vi.fn(() => Promise.resolve(remoteOk(undefined))) const face = { llm: { providers: vi.fn(() => Promise.resolve(ok({ @@ -173,24 +178,23 @@ function scriptedFace(overrides: { models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))), }, settings: { - describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))), + describe: vi.fn(() => Promise.resolve(remoteOk({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))), update, - replace, mutate, }, credentials: { - describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({ - credentials: Object.fromEntries(payload.refs.map(ref => [ref, { + describe: vi.fn((refs: string[]) => Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', ...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {}, writable: true, }])), - }))), + ))), set, unset, }, } - return { face, update, replace, mutate, set, unset } + return { face, update, mutate, set, unset } } type WireFace = ConstructorParameters[0] @@ -218,7 +222,7 @@ function cardSeatCalls( } async function mountFace(scripted: ReturnType) { - const { face, update, replace, mutate, set, unset } = scripted + const { face, update, mutate, set, unset } = scripted const mirror = new SettingsDescribeMirror(face as never) const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror) await controller.load() @@ -232,7 +236,7 @@ async function mountFace(scripted: ReturnType) { renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'], } const view = render() - return { view, face, update, replace, mutate, set, unset, controller, mirror, renderSlot } + return { view, face, update, mutate, set, unset, controller, mirror, renderSlot } } async function mountSection(overrides: Parameters[0] = {}) { @@ -245,10 +249,10 @@ async function mountSection(overrides: Parameters[0] = {}) */ async function mountFirstRun(overrides: Parameters[0] = {}) { const scripted = scriptedFace(overrides) - scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => - Promise.resolve(ok({ - credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), - }))) + scripted.face.credentials.describe.mockImplementation((refs: string[]) => + Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])), + ))) return mountFace(scripted) } @@ -295,12 +299,12 @@ describe('ModelsSection', () => { it('derives the draft seat\'s key fact from the page\'s conventional reference', async () => { const scripted = scriptedFace() - scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ - credentials: Object.fromEntries(payload.refs.map(ref => [ref, { + scripted.face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY' || ref === 'ANTHROPIC_API_KEY', writable: true, }])), - }))) + ))) const { renderSlot } = await mountFace(scripted) renderSlot.mockClear() fireEvent.click(screen.getByRole('button', { name: en.add })) @@ -332,7 +336,7 @@ describe('ModelsSection', () => { user: { enabled: true }, revision: 5, } - const update = vi.fn(() => Promise.resolve(ok(enabledNamespace))) + const update = vi.fn(() => Promise.resolve(remoteOk(enabledNamespace))) await mountSection({ update }) const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) @@ -340,18 +344,18 @@ describe('ModelsSection', () => { fireEvent.click(toggle) await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) - expect(update).toHaveBeenCalledWith({ - ns: 'subagent-model-selection', - patch: { enabled: true }, - expectedRevision: 4, - }) + expect(update).toHaveBeenCalledWith( + 'subagent-model-selection', + { enabled: true }, + 4, + ) expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) }) it('reports rejected subagent model-selection updates and permits a retry', async () => { const update = vi.fn() - .mockResolvedValueOnce(fail('revision changed')) - .mockResolvedValueOnce(ok({ + .mockResolvedValueOnce(remoteFail('revision changed', 'settings-rejected')) + .mockResolvedValueOnce(remoteOk({ ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, value: { enabled: true }, revision: 5, @@ -372,12 +376,12 @@ describe('ModelsSection', () => { ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, value: null, } as unknown as SettingsNamespaceView - const update = vi.fn() + const mutate = vi.fn() render( , @@ -387,7 +391,7 @@ describe('ModelsSection', () => { expect(toggle.getAttribute('aria-checked')).toBe('false') expect((toggle as HTMLButtonElement).disabled).toBe(true) fireEvent.click(toggle) - expect(update).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() }) it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { @@ -420,9 +424,9 @@ describe('ModelsSection', () => { it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => { const { face } = scriptedFace() - face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ - credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), - }))) + face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])), + ))) const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) await controller.load() render( { it('turns the setup card into a row once the credential reports configured', async () => { const { face } = await mountFirstRun() - face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ - credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])), - }))) + face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), + ))) const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) await controller.load() cleanup() @@ -503,12 +507,12 @@ describe('ModelsSection', () => { }) it('stores a typed key write-only from the setup card without touching settings', async () => { - const { set, update, face } = await mountFirstRun() + const { set, mutate, face } = await mountFirstRun() const key = screen.getByLabelText(en.keyInput) fireEvent.change(key, { target: { value: ' sk-live ' } }) fireEvent.click(screen.getByText(en.apply)) - await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) }) - expect(update).not.toHaveBeenCalled() + await waitFor(() => { expect(set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-live') }) + expect(mutate).not.toHaveBeenCalled() // The saved key re-loads the join; the settings answer rides the shared // mirror, so the reload shows as a directory read rather than a describe. await waitFor(() => { expect(face.llm.providers.mock.calls.length).toBeGreaterThan(1) }) @@ -520,8 +524,8 @@ describe('ModelsSection', () => { }) it('reuses the provider editor as a required credential-only onboarding form', async () => { - let finishSet: ((response: RpcResponse>) => void) | undefined - const set = vi.fn(() => new Promise>>((resolve) => { + let finishSet: ((response: { ok: true; value: undefined }) => void) | undefined + const set = vi.fn(() => new Promise<{ ok: true; value: undefined }>((resolve) => { finishSet = resolve })) const { face, mutate } = scriptedFace({ set }) @@ -567,13 +571,13 @@ describe('ModelsSection', () => { fireEvent.click(save) expect(await screen.findByText(en.onboardingSaving)).toBeTruthy() - expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-onboarding' }) + expect(set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-onboarding') expect(mutate).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled() if (finishSet === undefined) throw new Error('credential write did not start') await act(async () => { - finishSet?.(ok({})) + finishSet?.(remoteOk(undefined)) await Promise.resolve() }) expect(onClose).toHaveBeenCalledWith(true) @@ -581,7 +585,7 @@ describe('ModelsSection', () => { it('applies customized deepseek fields as path ops', async () => { const { mutate } = await mountDeepSeekCard({ - mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) const baseURL = screen.getByLabelText(en.baseUrl) @@ -593,16 +597,16 @@ describe('ModelsSection', () => { await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) // Only the field that actually changed: reasoningEffort was already // 'high' in the loaded profile, so it produces no op. - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-deepseek', - ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }], - expectedRevision: 0, - }) + expect(mutate.mock.calls[0]).toEqual([ + 'llm-deepseek', + [{ op: 'set', path: ['baseURL'], value: 'https://next2' }], + 0, + ]) }) it('materializes inherited models and adds an arbitrary DeepSeek id', async () => { const { mutate } = await mountDeepSeekCard({ - mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) expect(screen.getByText(en.modelsInherited)).toBeTruthy() @@ -620,9 +624,9 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-deepseek', - ops: [{ + expect(mutate.mock.calls[0]).toEqual([ + 'llm-deepseek', + [{ op: 'set', path: ['models'], value: [ @@ -630,8 +634,8 @@ describe('ModelsSection', () => { { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 }, ], }], - expectedRevision: 0, - }) + 0, + ]) }) it('rejects duplicate DeepSeek model ids before writing', async () => { @@ -706,7 +710,7 @@ describe('ModelsSection', () => { it('accepts a suffixed context window and stores the plain count', async () => { const { mutate } = await mountDeepSeekCard({ - mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) expandRow(1) @@ -730,9 +734,9 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-deepseek', - ops: [{ + expect(mutate.mock.calls[0]).toEqual([ + 'llm-deepseek', + [{ op: 'set', path: ['models'], value: [ @@ -740,8 +744,8 @@ describe('ModelsSection', () => { { ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 }, ], }], - expectedRevision: 0, - }) + 0, + ]) }) it('keeps unreadable context-window text on screen and refuses the write', async () => { @@ -773,7 +777,7 @@ describe('ModelsSection', () => { const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] } const overridden: SettingsNamespaceView = { ns: 'llm-deepseek', - schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, + schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, value: { ...stored, defaultContextWindow: 1_000_000 }, ...base === undefined ? {} : { base }, user: stored, @@ -858,7 +862,7 @@ describe('ModelsSection', () => { // inherited row displayed text no settings layer stores — and because an // unreadable buffer never settles, it stayed there indefinitely. const { mutate } = await mountDeepSeekCard({ - mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) expandRow(1) @@ -881,7 +885,7 @@ describe('ModelsSection', () => { it('edits an output cap per model and carries its text across a removal', async () => { const { mutate } = await mountDeepSeekCard({ - mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) expandRow(1) @@ -902,15 +906,15 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-deepseek', - ops: [{ + expect(mutate.mock.calls[0]).toEqual([ + 'llm-deepseek', + [{ op: 'set', path: ['models'], value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }], }], - expectedRevision: 0, - }) + 0, + ]) }) it('settles a pasted id and refuses whitespace that would never match', async () => { @@ -952,7 +956,7 @@ describe('ModelsSection', () => { it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { const { mutate } = await mountDeepSeekCard({ - mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), + mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement) @@ -969,9 +973,9 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-deepseek', - ops: [{ + expect(mutate.mock.calls[0]).toEqual([ + 'llm-deepseek', + [{ op: 'set', path: ['models'], value: [ @@ -979,33 +983,33 @@ describe('ModelsSection', () => { DEFAULT_DEEPSEEK_MODELS[1], ], }], - expectedRevision: 0, - }) + 0, + ]) }) it('clears an inherited override with an unset op, never a whole-section replace', async () => { // A whole-section replace would clobber sibling overrides to clear one field. - const { replace, update, mutate } = await mountDeepSeekCard() + const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const url = screen.getByLabelText(en.baseUrl) expect(url.value).toBe('https://base') fireEvent.change(url, { target: { value: '' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(replace).not.toHaveBeenCalled() - expect(update).not.toHaveBeenCalled() - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-deepseek', - ops: [{ op: 'unset', path: ['baseURL'] }], - expectedRevision: 0, - }) + // This editor clears one field through an unset op so it cannot clobber + // sibling overrides with a whole-section replacement. + expect(mutate.mock.calls[0]).toEqual([ + 'llm-deepseek', + [{ op: 'unset', path: ['baseURL'] }], + 0, + ]) }) it('pins the deepseek placeholder and clears typed input back to inherited', async () => { const { face } = scriptedFace() const bare: SettingsNamespaceView = { ns: 'llm-deepseek', - schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, + schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, value: {}, applies: 'live', secrets: [], @@ -1033,12 +1037,12 @@ describe('ModelsSection', () => { }) it('rejects an invalid draft before writing', async () => { - const { update } = await mountDeepSeekCard() + const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } }) fireEvent.click(screen.getByText(en.apply)) await screen.findByText(/baseURL/) - expect(update).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() }) it('edits a pi-ai profile with the curated fields only', async () => { @@ -1057,11 +1061,11 @@ describe('ModelsSection', () => { await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) // Only the edited field travels: apiKeyEnv and headers were already stored // with these values, so no op restates them. - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-pi-ai', - ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }], - expectedRevision: 0, - }) + expect(mutate.mock.calls[0]).toEqual([ + 'llm-pi-ai', + [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }], + 0, + ]) }) it('adds a dormant provider with a derived reference and stores its key', async () => { @@ -1079,12 +1083,12 @@ describe('ModelsSection', () => { fireEvent.change(addKey, { target: { value: 'sk-ant' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-pi-ai', - ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }], - expectedRevision: 0, - }) - await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) }) + expect(mutate.mock.calls[0]).toEqual([ + 'llm-pi-ai', + [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }], + 0, + ]) + await waitFor(() => { expect(set).toHaveBeenCalledWith('ANTHROPIC_API_KEY', 'sk-ant') }) }) it('keeps pi-ai provider-native authentication when no key is entered', async () => { @@ -1093,11 +1097,11 @@ describe('ModelsSection', () => { await screen.findByLabelText(en.provider) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-pi-ai', - ops: [{ op: 'set', path: ['providers', 'anthropic'], value: {} }], - expectedRevision: 0, - }) + expect(mutate.mock.calls[0]).toEqual([ + 'llm-pi-ai', + [{ op: 'set', path: ['providers', 'anthropic'], value: {} }], + 0, + ]) expect(set).not.toHaveBeenCalled() }) @@ -1115,10 +1119,10 @@ describe('ModelsSection', () => { } }, revision: 1, } - const mutate = vi.fn(() => Promise.resolve(ok(afterSettings))) + const mutate = vi.fn(() => Promise.resolve(remoteOk(afterSettings))) const set = vi.fn() - .mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected')) - .mockResolvedValueOnce(ok({})) + .mockResolvedValueOnce(remoteFail('credential store unavailable')) + .mockResolvedValueOnce(remoteOk(undefined)) const { face, controller, mirror } = await mountSection({ mutate, set }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) @@ -1126,7 +1130,7 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByText(en.apply)) await screen.findByText('credential store unavailable') expect(mutate).toHaveBeenCalledOnce() - face.settings.describe.mockResolvedValue(ok({ + face.settings.describe.mockResolvedValue(remoteOk({ writable: true, hasDocument: false, namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace), @@ -1141,7 +1145,7 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) expect(mutate).toHaveBeenCalledOnce() - expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) + expect(set).toHaveBeenLastCalledWith('ANTHROPIC_API_KEY', 'sk-ant') }) it('switches the add card target and degrades unknown or broken targets loudly', async () => { @@ -1161,7 +1165,7 @@ describe('ModelsSection', () => { it('surfaces a rejected settings write and never stores the key after it', async () => { const { set } = await mountSection({ - mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))), + mutate: vi.fn(() => Promise.resolve(remoteFail('llm-pi-ai: unknown pi-ai provider "bogus"', 'settings-rejected'))), }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) @@ -1202,7 +1206,7 @@ describe('ModelsSection', () => { // The stale-draft overwrite: two tabs open the same card, the other saves, // and this one must be refused rather than replay its opening snapshot. const { set } = await mountDeepSeekCard({ - mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))), + mutate: vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings-conflict'))), }) fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://mine' } }) @@ -1226,7 +1230,7 @@ describe('ModelsSection', () => { it('surfaces a shadowed credential write on the card', async () => { await mountFirstRun({ - set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))), + set: vi.fn(() => Promise.resolve(remoteFail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment'))), }) const key = screen.getByLabelText(en.keyInput) fireEvent.change(key, { target: { value: 'sk-live' } }) @@ -1237,11 +1241,11 @@ describe('ModelsSection', () => { it('locks the key input when the launch environment provides the credential', async () => { const { face } = await mountSection() - face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ - credentials: Object.fromEntries(payload.refs.map(ref => [ref, { + face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false, }])), - }))) + ))) fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) const editorKey = await screen.findByLabelText(en.keyInput) await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) }) @@ -1250,7 +1254,7 @@ describe('ModelsSection', () => { it('keeps a failed credential describe silent and the input usable', async () => { const { face, set } = await mountSection() - face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never) + face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'internal')) as never) fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) const editorKey = await screen.findByLabelText(en.keyInput) expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) @@ -1260,7 +1264,7 @@ describe('ModelsSection', () => { }) it('requires confirmation before removing a user-added provider', async () => { - const { replace, mutate, unset } = await mountSection() + const { mutate, unset } = await mountSection() fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }) expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential)) @@ -1280,20 +1284,20 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })) .getByRole('button', { name: openaiCopy(en.deleteConfirm) })) - await waitFor(() => { expect(unset).toHaveBeenCalledWith({ ref: 'OPENAI_API_KEY' }) }) + await waitFor(() => { expect(unset).toHaveBeenCalledWith('OPENAI_API_KEY') }) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number) expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() - expect(replace).not.toHaveBeenCalled() - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-pi-ai', - ops: [{ op: 'unset', path: ['providers', 'openai'] }], - }) + expect(mutate.mock.calls[0]).toEqual([ + 'llm-pi-ai', + [{ op: 'unset', path: ['providers', 'openai'] }], + undefined, + ]) }) it('blocks duplicate deletion while the confirmed removal is pending', async () => { - let resolveRemoval!: (response: RpcResponse) => void - const mutate = vi.fn(() => new Promise>((resolve) => { + let resolveRemoval!: (response: { ok: true; value: SettingsNamespaceView }) => void + const mutate = vi.fn(() => new Promise<{ ok: true; value: SettingsNamespaceView }>((resolve) => { resolveRemoval = resolve })) await mountSection({ mutate }) @@ -1309,7 +1313,7 @@ describe('ModelsSection', () => { fireEvent.click(within(dialog).getByRole('button', { name: en.close })) expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog) expect(mutate).toHaveBeenCalledOnce() - await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) }) + await act(async () => { resolveRemoval(remoteOk(wireNamespaces()[2]!)) }) await waitFor(() => { expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() }) @@ -1336,7 +1340,7 @@ describe('ModelsSection', () => { it('shows the read-only notice and disables mutations for a read-only provider', async () => { const { face } = await mountSection() - face.settings.describe.mockImplementation(() => Promise.resolve(ok({ + face.settings.describe.mockImplementation(() => Promise.resolve(remoteOk({ writable: false, hasDocument: false, namespaces: wireNamespaces(), @@ -1358,7 +1362,7 @@ describe('ModelsSection', () => { }) it('toggles the row editor closed on a second edit click and on cancel', async () => { - const { update } = await mountSection() + const { mutate } = await mountSection() const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) }) fireEvent.click(edit) await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) }) @@ -1368,7 +1372,7 @@ describe('ModelsSection', () => { await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) }) fireEvent.click(screen.getByText(en.cancel)) expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) - expect(update).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() }) it('cancels the add card back to the add button', async () => { @@ -1420,22 +1424,22 @@ describe('ModelsSection', () => { it('removes by unsetting the profile path, never by rebuilding the section', async () => { // The page only needs to name the profile path; rebuilding the section // would widen the write for no benefit. - const { face, mutate, replace, controller } = await mountSection() + const { face, mutate, controller } = await mountSection() await removeProviderProfile( face as unknown as Parameters[0], controller, { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] }, ) - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-plain', - ops: [{ op: 'unset', path: ['ghost-profile'] }], - }) - expect(replace).not.toHaveBeenCalled() + expect(mutate.mock.calls[0]).toEqual([ + 'llm-plain', + [{ op: 'unset', path: ['ghost-profile'] }], + undefined, + ]) }) it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => { const { face, controller } = await mountSection({ - mutate: vi.fn(() => Promise.resolve(fail('read-only'))), + mutate: vi.fn(() => Promise.resolve(remoteFail('read-only', 'settings-rejected'))), }) const before = controller.store.getSnapshot().rows const failure = await removeProviderProfile( @@ -1449,8 +1453,8 @@ describe('ModelsSection', () => { it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => { const mutate = vi.fn() - .mockResolvedValueOnce(fail('the host refused')) - .mockResolvedValueOnce(ok(wireNamespaces()[2]!)) + .mockResolvedValueOnce(remoteFail('the host refused', 'settings-rejected')) + .mockResolvedValueOnce(remoteOk(wireNamespaces()[2]!)) const { unset } = await mountSection({ mutate }) fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }) @@ -1478,15 +1482,16 @@ describe('ModelsSection', () => { fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) })) await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) expect(unset).not.toHaveBeenCalled() - expect(mutate.mock.calls[0]?.[0]).toEqual({ - ns: 'llm-pi-ai', - ops: [{ op: 'unset', path: ['providers', 'zombie'] }], - }) + expect(mutate.mock.calls[0]).toEqual([ + 'llm-pi-ai', + [{ op: 'unset', path: ['providers', 'zombie'] }], + undefined, + ]) }) it('does not remove provider settings when its managed credential removal is refused', async () => { const { face, controller, mutate } = await mountSection({ - unset: vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))), + unset: vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))), }) const failure = await removeProviderProfile( face as unknown as Parameters[0], diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index e849904345..d45736cd6d 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' @@ -21,11 +21,12 @@ let nextRpc = 0 function ok(value: T): RpcResponse { return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } } } -function fail(message: string): RpcResponse { - return { - rpcId: `onboarding-${nextRpc++}` as never, - result: { ok: false, error: { code: 'internal', message, details: {} } }, - } +/** Credentials answers over the Remote carrier, which has no envelope. */ +function remoteOk(value: T) { + return { ok: true as const, value } +} +function remoteFail(message: string) { + return { ok: false as const, error: { code: 'internal', message, details: {} } } } const DeepSeekConfig = Schema.object({ @@ -49,7 +50,7 @@ function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView { const value = apiKeyEnv === null ? {} : { apiKeyEnv } return { ns: 'llm-deepseek', - schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, + schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, value, base: value, user: {}, @@ -81,12 +82,12 @@ function harness(options: { let fileConfigured = false const configured = options.configured ?? (() => fileConfigured) const apiKeyEnv = options.apiKeyEnv === undefined ? 'DEEPSEEK_API_KEY' : options.apiKeyEnv - const mutate = vi.fn(() => Promise.resolve(ok(deepSeekNamespace(apiKeyEnv)))) - const set = vi.fn((_payload: { ref: string; value: string }) => { + const mutate = vi.fn(() => Promise.resolve(remoteOk(deepSeekNamespace(apiKeyEnv)))) + const set = vi.fn((_ref: string, _value: string) => { if (options.setReject !== undefined) return Promise.reject(new Error(options.setReject)) - if (options.setFailure !== undefined) return Promise.resolve(fail(options.setFailure)) + if (options.setFailure !== undefined) return Promise.resolve(remoteFail(options.setFailure)) fileConfigured = true - return Promise.resolve(ok({})) + return Promise.resolve(remoteOk(undefined)) }) const face = { llm: { @@ -106,7 +107,7 @@ function harness(options: { }, }, settings: { - describe: () => Promise.resolve(ok({ + describe: () => Promise.resolve(remoteOk({ writable: options.settingsWritable ?? true, hasDocument: false, namespaces: options.settingsNamespace === false ? [] : [deepSeekNamespace(apiKeyEnv)], @@ -115,18 +116,16 @@ function harness(options: { }, credentials: { describe: () => options.describeFailure === undefined - ? Promise.resolve(ok({ - credentials: { - DEEPSEEK_API_KEY: { - configured: configured(), - ...configured() && options.credential?.source !== undefined - ? { source: options.credential.source } - : {}, - writable: options.credential?.writable ?? true, - }, + ? Promise.resolve(remoteOk({ + DEEPSEEK_API_KEY: { + configured: configured(), + ...configured() && options.credential?.source !== undefined + ? { source: options.credential.source } + : {}, + writable: options.credential?.writable ?? true, }, })) - : Promise.resolve(fail(options.describeFailure)), + : Promise.resolve(remoteFail(options.describeFailure)), set, }, } diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 89c30f710b..e6168398af 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -4,7 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' @@ -45,15 +45,22 @@ function ok(value: T): RpcResponse { function fail(message: string, code: string): RpcResponse { return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code, message, details: {} } as never } } } +/** Credentials answers over the Remote carrier, which has no envelope. */ +function remoteOk(value: T) { + return { ok: true as const, value } +} +function remoteFail(message: string, code = 'credential-rejected') { + return { ok: false as const, error: { code, message, details: {} } } +} function piAiNamespace( - providers: Record, - userProviders: Record = providers, - baseProviders: Record = {}, + providers: Record, + userProviders: Record = providers, + baseProviders: Record = {}, ): SettingsNamespaceView { return { ns: 'llm-pi-ai', - schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown, + schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as JsonValue, // `value` is the effective section; `user` is only the layer this page // writes. They differ whenever a composition `base` supplies something. value: { providers }, @@ -66,11 +73,11 @@ function piAiNamespace( } function scriptedFace(options: { - providers?: Record + providers?: Record /** User layer, when it differs from the effective section. */ - userProviders?: Record + userProviders?: Record /** Composition layer, for a route a `cordis.yml` pins rather than the page. */ - baseProviders?: Record + baseProviders?: Record /** Routes the adapter reports as hand-declared; the rest come back as shipped. */ declaredRoutes?: readonly string[] discover?: ReturnType @@ -82,8 +89,8 @@ function scriptedFace(options: { } const namespace = piAiNamespace(providers, options.userProviders ?? providers, options.baseProviders ?? {}) const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] }))) - const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace))) - const set = options.set ?? vi.fn(() => Promise.resolve(ok({}))) + const mutate = options.mutate ?? vi.fn(() => Promise.resolve(remoteOk(namespace))) + const set = options.set ?? vi.fn(() => Promise.resolve(remoteOk(undefined))) const face = { llm: { providers: vi.fn(() => Promise.resolve(ok({ @@ -100,15 +107,13 @@ function scriptedFace(options: { discoverModels: discover, }, settings: { - describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace] }))), - update: vi.fn(), - replace: vi.fn(), + describe: vi.fn(() => Promise.resolve(remoteOk({ writable: true, namespaces: [namespace] }))), mutate, }, credentials: { - describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({ - credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), - }))), + describe: vi.fn((refs: string[]) => Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])), + ))), set, unset: vi.fn(), }, @@ -132,11 +137,16 @@ function firstProbe(discover: ReturnType): unknown { return call } -/** The first recorded settings write; fails the case when nothing was written. */ +/** + * The first recorded settings write, as one record. The Remote method takes + * three positional arguments; the cases read the write as a whole, so the + * regrouping lives here rather than in every assertion. + */ function firstMutate(mutate: ReturnType): MutateCall { - const call = mutate.mock.calls[0]?.[0] as MutateCall | undefined + const call = mutate.mock.calls[0] as [string, MutateCall['ops'], number | undefined] | undefined if (call === undefined) throw new Error('no settings write was recorded') - return call + const [ns, ops, expectedRevision] = call + return { ns, ops, ...expectedRevision === undefined ? {} : { expectedRevision } } } async function mountSection(options: Parameters[0] = {}) { @@ -190,7 +200,7 @@ describe('protocolChoices', () => { const { namespace } = scriptedFace() expect(protocolChoices(namespace, settingsSchema)).toEqual(PROTOCOLS) expect(protocolChoices(undefined, settingsSchema)).toEqual([]) - const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as unknown } + const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as JsonValue } expect(protocolChoices(plain, settingsSchema)).toEqual([]) await Promise.resolve() }) @@ -734,7 +744,7 @@ describe('hand-declared providers', () => { // meanwhile makes this a conflict rather than an overwrite. expectedRevision: 7, }) - expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' }) + expect(set).toHaveBeenCalledWith('ACME_GATEWAY_API_KEY', 'gw-key') }) it('scopes each card to fields a provider can actually own', async () => { @@ -901,8 +911,8 @@ describe('hand-declared providers', () => { it('retries only the key after the profile landed, and reports the provider on cancel', async () => { const set = vi.fn() - .mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected')) - .mockResolvedValueOnce(ok({})) + .mockResolvedValueOnce(remoteFail('credential store is read-only')) + .mockResolvedValueOnce(remoteOk(undefined)) const { mutate, onClose } = mountCard({}, { set }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) @@ -917,7 +927,7 @@ describe('hand-declared providers', () => { expect(onClose).not.toHaveBeenCalled() expect(mutate).toHaveBeenCalledTimes(1) // The key is stored trimmed, matching the editor. - expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' }) + expect(set).toHaveBeenNthCalledWith(1, 'ACME_API_KEY', 'gw-key') // The provider exists now, so the fields describing it are settled and // only the key can still be corrected. @@ -932,11 +942,11 @@ describe('hand-declared providers', () => { // first write superseded, so the Host would answer settings-conflict and // the key could never be stored from here at all. expect(mutate).toHaveBeenCalledTimes(1) - expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' }) + expect(set).toHaveBeenNthCalledWith(2, 'ACME_API_KEY', 'gw-key-2') }) it('reports the created provider when cancelled after its profile landed', async () => { - const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected')) + const set = vi.fn().mockResolvedValue(remoteFail('nope')) const { onClose } = mountCard({}, { set }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) @@ -1122,7 +1132,7 @@ describe('hand-declared providers', () => { }) it('surfaces a refused write and a rejected transport without closing', async () => { - const refused = vi.fn(() => Promise.resolve(fail('read-only settings', 'settings-rejected'))) + const refused = vi.fn(() => Promise.resolve(remoteFail('read-only settings', 'settings-rejected'))) const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: refused }).face } as never }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) @@ -1150,7 +1160,7 @@ describe('hand-declared providers', () => { }) it('reports a stored profile whose key write was refused', async () => { - const set = vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))) + const set = vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))) const { onClose } = mountCard({ api: { ...scriptedFace({ set }).face } as never }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) @@ -1363,7 +1373,7 @@ describe('API key field', () => { fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalled() }) - expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc') + expect(set.mock.calls[0]?.[1]).toBe('sk-abc') }) it('blocks the interrogation too, rather than spending a round trip on a refused key', async () => { diff --git a/packages/client/ui-settings-models/tests/readiness.client.spec.ts b/packages/client/ui-settings-models/tests/readiness.client.spec.ts index f01e821767..3bea0c072d 100644 --- a/packages/client/ui-settings-models/tests/readiness.client.spec.ts +++ b/packages/client/ui-settings-models/tests/readiness.client.spec.ts @@ -1,10 +1,10 @@ /** Pure first-run readiness projection over the shared Models join. */ import { describe, expect, it } from 'vitest' -import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client' +import type { CredentialInfo } from '@deepseek-ai/dsh-api-remotes/client' import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts' import { onboardingReadiness, providerUsable } from '../src/client/store.ts' -const missingCredential: CredentialView = { configured: false, writable: true } +const missingCredential: CredentialInfo = { configured: false, writable: true } function row(overrides: Partial = {}): ProviderRow { return { diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index c477c25993..677ca14418 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -13,6 +13,17 @@ function fail(message: string): RpcResponse { return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } } } +/** Credentials answers over the Remote carrier, which has no envelope. */ +type RemoteAnswer = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: { code: string; message: string; details: object } } +function remoteOk(value: T): RemoteAnswer { + return { ok: true, value } +} +function remoteFail(message: string): RemoteAnswer { + return { ok: false, error: { code: 'internal', message, details: {} } } +} + const DIRECTORY = [ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, @@ -43,8 +54,8 @@ const NAMESPACES = [ function api(overrides: { providers?: () => Promise> - describeSettings?: () => Promise> - describeCredentials?: (refs: string[]) => Promise }>> + describeSettings?: () => Promise> + describeCredentials?: (refs: readonly string[]) => Promise>> } = {}) { const seenRefs: string[][] = [] const face = { @@ -53,19 +64,19 @@ function api(overrides: { models: () => Promise.resolve(ok({ groups: [], failures: [] })), }, settings: { - describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))), - update: () => Promise.resolve(fail('unused')), - replace: () => Promise.resolve(fail('unused')), + describe: overrides.describeSettings + ?? (() => Promise.resolve(remoteOk({ writable: true, hasDocument: false, namespaces: NAMESPACES }))), + mutate: () => Promise.resolve(remoteFail('the store spec issues no writes')), }, credentials: { - describe: (payload: { refs: string[] }) => { - seenRefs.push(payload.refs) - return (overrides.describeCredentials ?? (refs => Promise.resolve(ok({ - credentials: Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])), - }))))(payload.refs) + describe: (refs: readonly string[]) => { + seenRefs.push([...refs]) + return (overrides.describeCredentials ?? (asked => Promise.resolve(remoteOk( + Object.fromEntries(asked.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])), + ))))(refs) }, - set: () => Promise.resolve(ok({})), - unset: () => Promise.resolve(ok({})), + set: () => Promise.resolve(remoteOk(undefined)), + unset: () => Promise.resolve(remoteOk(undefined)), }, } const wire = face as never @@ -104,7 +115,7 @@ describe('ModelsSettingsStore', () => { }) it('degrades the credential badge, not the page, when the credential domain fails', async () => { - const { face, mirror } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) }) + const { face, mirror } = api({ describeCredentials: () => Promise.resolve(remoteFail('no provider')) }) const store = new ModelsSettingsStore(face, settingsSchema, mirror) await store.load() const state = store.store.getSnapshot() @@ -173,7 +184,7 @@ describe('ModelsSettingsStore', () => { describe('edge joins', () => { it('treats a non-object profile as having no credential reference', async () => { const { face, mirror } = api({ - describeSettings: () => Promise.resolve(ok({ + describeSettings: () => Promise.resolve(remoteOk({ writable: true, hasDocument: false, namespaces: [{ @@ -200,7 +211,7 @@ describe('edge joins', () => { it('describes the derived reference for a row whose profile names none', async () => { const { face, mirror, seenRefs } = api({ - describeSettings: () => Promise.resolve(ok({ + describeSettings: () => Promise.resolve(remoteOk({ writable: true, hasDocument: false, namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never, @@ -210,9 +221,9 @@ describe('edge joins', () => { { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, ] as never, })), - describeCredentials: refs => Promise.resolve(ok({ - credentials: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), - })), + describeCredentials: refs => Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), + )), }) const store = new ModelsSettingsStore(face, settingsSchema, mirror) await store.load() @@ -226,7 +237,7 @@ describe('edge joins', () => { }) it('surfaces a settings describe failure', async () => { - const { face, mirror } = api({ describeSettings: () => Promise.resolve(fail('settings down')) }) + const { face, mirror } = api({ describeSettings: () => Promise.resolve(remoteFail('settings down')) }) const store = new ModelsSettingsStore(face, settingsSchema, mirror) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' }) @@ -252,8 +263,8 @@ describe('edge joins', () => { describeSettings: () => { settingsCall += 1 return Promise.resolve(settingsCall === 1 - ? ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }) - : fail('settings refresh down')) + ? remoteOk({ writable: true, hasDocument: false, namespaces: NAMESPACES }) + : remoteFail('settings refresh down')) }, }) const store = new ModelsSettingsStore(face, settingsSchema, mirror) diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index c9fe3498ce..1a48af9d1c 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -29,8 +29,9 @@ afterEach(() => { document.getElementById('root')?.remove() }) -function response(value: T) { - return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } } +/** The settings namespace answers over the Remote carrier, which has no envelope. */ +function remoteAnswer(value: T) { + return { ok: true as const, value } } function welcomeView(value: unknown, revision = 0) { @@ -53,7 +54,7 @@ const useSessionPendingInteraction: WelcomeNoticeProps['useSessionPendingInterac function mount( version?: string, mutateImpl: () => Promise = () => - Promise.resolve(response(welcomeView({ [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, 1))), + Promise.resolve(remoteAnswer(welcomeView({ [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, 1))), ) { const appRoot = document.createElement('div') appRoot.id = 'root' @@ -61,7 +62,7 @@ function mount( const mutate = vi.fn(mutateImpl) const api = { settings: { - describe: () => Promise.resolve(response({ + describe: () => Promise.resolve(remoteAnswer({ writable: true, hasDocument: false, namespaces: [welcomeView(version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version })], diff --git a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts index efcb2aefdb..d0927e34f0 100644 --- a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { Context } from '@deepseek-ai/cordis' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -11,9 +10,9 @@ import { const schemaService = new SettingsSchemaService(new Context()) -let rpc = 0 -function ok(value: T): RpcResponse { - return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } } +/** The settings namespace answers over the Remote carrier, which has no envelope. */ +function ok(value: T) { + return { ok: true as const, value } } function namespace(value: unknown = {}, revision = 0) { @@ -91,11 +90,11 @@ describe('WelcomeNoticeStore', () => { await mirror.load() await controller.load() await expect(controller.acknowledge()).resolves.toBe(true) - expect(mutate).toHaveBeenCalledWith({ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }], - expectedRevision: 3, - }) + expect(mutate).toHaveBeenCalledWith( + WELCOME_NOTICE_SETTINGS_NAMESPACE, + [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }], + 3, + ) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) // The write answer folded into the mirror; no re-read followed. expect(describeCall).toHaveBeenCalledTimes(1) diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 7edd06ff4e..75b87d26fb 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" @@ -48,7 +47,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -57,7 +55,6 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index aababb7d89..11dc449b3e 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -9,7 +9,6 @@ * settings scope, which keeps them unaware of one another and of other tabs. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the settings shell's SlotMap merge (the 'settings.section' entry) @@ -50,20 +49,20 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'] /** * Mount the plugin configuration section and the cards this package ships. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const { api } = ctx.get('connection') as ConnectionHandle const t = ctx.locale.bind(NS) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries') const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS })) const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) - const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api) + const webSearch = new WebSearchCardController( + ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) // The credential a card reports is not part of any settings section, so its // scope publishes nothing when one is written. This is the only signal that diff --git a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts index e329359230..924ba688d5 100644 --- a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts @@ -9,7 +9,7 @@ * covers everything the card shows. */ -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { @@ -39,13 +39,16 @@ export interface WebSearchSettings { maxUses?: number } +/** The credentials Remote methods this card reads and writes through. */ +export type WebSearchCredentials = Pick + /** What the credentials domain last reported, and for which reference. */ interface CredentialState { /** Reference this answer describes; a stale response for another one is dropped. */ ref: string /** Whether any layer supplies a value for it. */ configured: boolean - /** Whether `credentials.set` can affect it; false disables the control. */ + /** Whether `credentials/set` can affect it; false disables the control. */ writable: boolean } @@ -79,11 +82,11 @@ export class WebSearchCardController { /** * @param scope - the bound settings scope for the `web-search-deepseek` namespace. - * @param api - wire face used for the credential the section references. + * @param credentials - Remote face used for the credential the section references. */ constructor( private readonly scope: SettingsScope, - private readonly api: Pick, + private readonly credentials: WebSearchCredentials, ) { this.form = new CardForm( scope, @@ -122,16 +125,16 @@ export class WebSearchCardController { this.credential = { ref, configured: false, writable: true } this.store.set(this.projection()) } - let response: Awaited> + let response: Awaited> try { - response = await this.api.credentials.describe({ refs: [ref] }) + response = await this.credentials.describe([ref]) } catch (_credentialReadFailure) { // The card stays usable without this: the key control simply reports the // last state it knew, and a write still reaches the Host. return } - if (!response.result.ok || ref !== refOf(this.scope.getSnapshot())) return - const view = response.result.value.credentials[ref] + if (!response.ok || ref !== refOf(this.scope.getSnapshot())) return + const view = response.value[ref] const next: CredentialState = { ref, configured: view?.configured ?? false, @@ -172,7 +175,7 @@ export class WebSearchCardController { */ private async writeKey(value: string): Promise { try { - await this.api.credentials.set({ ref: refOf(this.scope.getSnapshot()), value }) + await this.credentials.set(refOf(this.scope.getSnapshot()), value) } catch (_credentialWriteFailure) { // Refusals surface through the re-read below: the Host is the only // authority on whether the key now exists. diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index b1ecdfa132..bba43edcbc 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -26,30 +26,24 @@ async function bench(served?: string[]) { const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) + const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined - ? { rpcId: 's', result: { ok: false, error: {} } } + ? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } } : { - rpcId: 's', - result: { - ok: true, - value: { - writable: true, - hasDocument: true, - namespaces: served.map(ns => ({ - ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0, - })), - }, + ok: true, + value: { + writable: true, + hasDocument: true, + namespaces: served.map(ns => ({ + ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0, + })), }, })) - const remote = new TestRemote(ctx) - ctx.provide('connection', { - isLoopback: true, - api: { - settings: { describe: describeSettings }, - credentials: { describe: describeCredentials }, - }, - } as never) + const remote = new TestRemote(ctx, { + credentials: { describe: describeCredentials, set: vi.fn() }, + settings: { describe: describeSettings }, + }) + ctx.provide('connection', { isLoopback: true, api: {} } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } } @@ -63,7 +57,7 @@ function declareRoot(slots: SlotRegistry): () => void { describe('ui-settings-plugins apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']) }) it('registers one Plugins section and declares the tab and card slots', async () => { diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 396678698b..505079be0c 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -30,11 +30,11 @@ function acceptWrites(host: StubSettingsScope): void { function credentialsApi(configured: boolean) { const describe = vi.fn(() => Promise.resolve({ - rpcId: 'c-1' as never, - result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } }, + ok: true as const, + value: { DEEPSEEK_API_KEY: { configured, writable: true } }, })) - const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } })) - return { api: { credentials: { describe, set } } as never, describe, set } + const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) + return { api: { describe, set } as never, describe, set } } describe('CardForm', () => { @@ -412,13 +412,13 @@ describe('WebSearchCardController', () => { expect(credentials.set).not.toHaveBeenCalled() credentials.describe.mockImplementation(() => Promise.resolve({ - rpcId: 'c-1' as never, - result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } }, + ok: true as const, + value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, })) face.save() await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) - expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' }) + expect(credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret') expect(host.set).not.toHaveBeenCalled() await vi.waitFor(() => { expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true }) @@ -454,8 +454,8 @@ describe('WebSearchCardController', () => { // A key written on another surface reaches this card only through this signal. credentials.describe.mockImplementation(() => Promise.resolve({ - rpcId: 'c-1' as never, - result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } }, + ok: true as const, + value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, })) controller.refreshCredential('DEEPSEEK_API_KEY') @@ -475,7 +475,7 @@ describe('WebSearchCardController', () => { face.save() await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) - expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' }) + expect(credentials.set).toHaveBeenCalledWith('SEARCH_KEY', 'ds-secret') }) it('reports a key the Host did not store as a failed save', async () => { @@ -497,7 +497,7 @@ describe('WebSearchCardController', () => { const host = stubSettingsScope() const describe = vi.fn(() => Promise.reject(new Error('offline'))) const set = vi.fn(() => Promise.reject(new Error('offline'))) - const controller = new WebSearchCardController(host.scope, { credentials: { describe, set } } as never) + const controller = new WebSearchCardController(host.scope, { describe, set }) const face = controller.inject() await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) @@ -516,10 +516,10 @@ describe('WebSearchCardController', () => { it('ignores a credential read the Host refused', async () => { const host = stubSettingsScope() const describe = vi.fn(() => Promise.resolve({ - rpcId: 'c-1' as never, - result: { ok: false as const, error: { code: 'credentials-unavailable', message: 'no provider' } }, + ok: false as const, + error: { code: 'internal', message: 'no credential provider', details: {} }, })) - const controller = new WebSearchCardController(host.scope, { credentials: { describe, set: vi.fn() } } as never) + const controller = new WebSearchCardController(host.scope, { describe, set: vi.fn() }) await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false) @@ -546,16 +546,13 @@ describe('WebSearchCardController', () => { describe('ConfigurablePluginsTabController', () => { function settingsApi(namespaces: string[]) { const describe = vi.fn(() => Promise.resolve({ - rpcId: 's-1' as never, - result: { - ok: true as const, - value: { - writable: true, - hasDocument: true, - namespaces: namespaces.map(ns => ({ - ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0, - })), - }, + ok: true as const, + value: { + writable: true, + hasDocument: true, + namespaces: namespaces.map(ns => ({ + ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0, + })), }, })) return { mirror: new SettingsDescribeMirror({ settings: { describe } } as never), describe } diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 66eca29c22..23f289e527 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -32,13 +32,15 @@ export type { SettingsScopeController, SettingsScopeBinder } from './settings-sc export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts' export type { SettingsSchemaService } from './schema.ts' export type { SchemaNode } from './schema.ts' -export type { SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' +export type { + SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot, SettingsRemote, SettingsWireFace, +} from './settings-mirror.ts' /** * Required services: the wire handle for the mirror's reads and the forwarded * settings invalidation the mirror refreshes on. */ -export const inject = ['connection', 'remote'] +export const inject = ['connection', 'remote', 'remote.settings'] /** * Provide the settings-namespace scope service over one shared describe @@ -52,10 +54,10 @@ export const inject = ['connection', 'remote'] export function apply(ctx: Context): void { const schema = new SettingsSchemaService(ctx) const connection = ctx.get('connection') as ConnectionHandle - const mirror = new SettingsDescribeMirror( - connection.api, - connection.isLoopback ? 'host' : 'memory', - ) + // Captured once here, where `remote.settings` is declared in this plugin's + // own `inject`; the binder hands the same face to every scope it binds. + const wire = { settings: ctx.remote.settings } + const mirror = new SettingsDescribeMirror(wire, connection.isLoopback ? 'host' : 'memory') ctx.effect(() => { const disposers = [ ctx.remote.$on('settings/document-updated', () => { void mirror.load() }), @@ -68,5 +70,5 @@ export function apply(ctx: Context): void { void mirror.ensure() return () => { for (const dispose of disposers) dispose() } }, 'ui-settings: describe mirror invalidations') - new SettingsScopeBinder(ctx, { mirror, schema }) + new SettingsScopeBinder(ctx, { mirror, schema, wire }) } diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index bd4aedfc16..f7a6c15a22 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -9,10 +9,24 @@ * through {@link SettingsDescribeMirror.acceptView}. */ -import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -type SettingsFace = Pick +/** + * The settings Remote methods browser configuration surfaces may reach: the + * redacted read plus merge, replacement, and path-addressed writes. + * Named once here so the consumers share one face instead of each re-deriving + * it from the namespace. + */ +export type SettingsRemote = Pick + +/** Wire face carrying the settings Remote namespace. */ +export interface SettingsWireFace { + /** The settings Remote namespace. */ + settings: SettingsRemote +} + +type SettingsFace = SettingsWireFace /** The full `settings.describe` answer the mirror serves. */ export interface SettingsDescribeView { @@ -180,10 +194,10 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { const generation = ++this.generation let outcome: { view: SettingsDescribeView } | { failure: string } try { - const response = await this.api.settings.describe({}) - outcome = response.result.ok - ? { view: response.result.value } - : { failure: response.result.error.message } + const response = await this.api.settings.describe() + outcome = response.ok + ? { view: response.value } + : { failure: response.error.message } } catch (error) { outcome = { failure: error instanceof Error ? error.message : String(error) } } diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 1b06940577..d4ddd632e5 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -9,7 +9,7 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { - ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, + ConnectionHandle, JsonValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' // Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this @@ -30,9 +30,9 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' import type {} from '@deepseek-ai/dsh-settings/types' import type { SettingsSchemaService } from './schema.ts' import type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts' -import { SettingsDescribeMirror, type SettingsDescribeFace } from './settings-mirror.ts' +import { SettingsDescribeMirror, type SettingsDescribeFace, type SettingsWireFace } from './settings-mirror.ts' -type SettingsFace = Pick +type SettingsFace = SettingsWireFace /** * One namespace's derived view over the shared describe mirror, plus that @@ -104,7 +104,7 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the write and any latest-write recovery read. */ set(field: string, value: unknown): Promise { - return this.write({ op: 'set', path: [field], value }) + return this.write({ op: 'set', path: [field], value: value as JsonValue }) } /** @@ -123,25 +123,21 @@ export class SettingsScopeController implements SettingsScope { const revision = this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { - response = await this.api.settings.mutate({ - ns: this.spec.namespace, - ops: [op], - ...(revision === undefined ? {} : { expectedRevision: revision }), - }) + response = await this.api.settings.mutate(this.spec.namespace, [op], revision) } catch (_settingsWriteFailure) { await this.recover(generation) return } - if (!response.result.ok) { + if (!response.ok) { await this.recover(generation) return } if (this.disposed) return if (generation === this.writeGeneration) { this.pendingRevision = undefined - this.mirror.acceptView(response.result.value) + this.mirror.acceptView(response.value) } else { - this.pendingRevision = response.result.value.revision + this.pendingRevision = response.value.revision } }) } @@ -235,16 +231,26 @@ declare module '@deepseek-ai/cordis' { export class SettingsScopeBinder extends Service { private readonly mirror: SettingsDescribeMirror private readonly schema: SettingsSchemaService + private readonly wire: SettingsWireFace /** * @param ctx - the providing plugin's context. * @param config - the shared describe mirror every bound scope derives from, - * plus the settings-owned schema operations. + * the settings-owned schema operations, and the settings Remote namespace the + * bound scopes write through. The namespace is captured here rather than read + * inside {@link bind}, because a Service reads `ctx` as its *consumer's* + * fiber: reading it there would make every caller declare `remote.settings` + * in its own `inject`. */ - constructor(ctx: Context, config: { mirror: SettingsDescribeMirror; schema: SettingsSchemaService }) { + constructor(ctx: Context, config: { + mirror: SettingsDescribeMirror + schema: SettingsSchemaService + wire: SettingsWireFace + }) { super(ctx, 'settingsScope') this.mirror = config.mirror this.schema = config.schema + this.wire = config.wire } /** @@ -272,7 +278,7 @@ export class SettingsScopeBinder extends Service { const ctx = this.ctx const connection = ctx.get('connection') as ConnectionHandle const controller = new SettingsScopeController( - connection.api, + this.wire, spec, this.mirror, connection.isLoopback ? 'host' : 'memory', diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index ebd385b78d..a5a7a85053 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -7,15 +7,11 @@ import { SettingsScopeBinder } from '../src/client/settings-scope.ts' function bench() { const describeCall = vi.fn().mockResolvedValue({ - rpcId: 'plugin-bench' as never, - result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [] } }, + ok: true, value: { writable: true, hasDocument: true, namespaces: [] }, }) const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe: describeCall } }, - isLoopback: true, - } as never) - const remote = new TestRemote(ctx) + ctx.provide('connection', { api: {}, isLoopback: true } as never) + const remote = new TestRemote(ctx, { settings: { describe: describeCall } }) return { ctx, describeCall, remote, fiber: ctx.plugin({ inject: [...inject], apply }) } } diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts index 6439a3000e..92a5c0fa11 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -1,28 +1,25 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts' -let rpc = 0 +/** What a Remote call answers with: no carrier envelope, and a free-form failure code. */ +type Answer = + | { ok: true; value: T } + | { ok: false; error: { code: string; message: string; details: object } } -function ok(value: T): RpcResponse { - return { rpcId: `mirror-${rpc++}` as never, result: { ok: true, value } } +function ok(value: T): Answer { + return { ok: true, value } } -function rejected(message: string): RpcResponse { - return { - rpcId: `mirror-${rpc++}` as never, - result: { - ok: false, - error: { code: 'settings-rejected', message, details: { ns: 'theme' } }, - }, - } +function rejected(message: string): Answer { + return { ok: false, error: { code: 'settings-rejected', message, details: { ns: 'theme' } } } } function view(ns: string, revision = 0): SettingsNamespaceView { return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision } } -function described(namespaces: SettingsNamespaceView[]): RpcResponse { +function described(namespaces: SettingsNamespaceView[]): Answer { return ok({ writable: true, hasDocument: true, namespaces }) } @@ -34,7 +31,7 @@ function deferred() { describe('SettingsDescribeMirror', () => { it('folds loads before the wire read into it, and mid-flight loads into one rerun', async () => { - const gate = deferred>() + const gate = deferred>() const describeCall = vi.fn() .mockReturnValueOnce(gate.promise) .mockResolvedValue(described([view('theme', 1)])) @@ -145,7 +142,7 @@ describe('SettingsDescribeMirror', () => { }) it('starts no second run for a load issued inside the loading publish', async () => { - const gate = deferred>() + const gate = deferred>() const describeCall = vi.fn().mockReturnValue(gate.promise) const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) let reentered = false @@ -181,7 +178,7 @@ describe('SettingsDescribeMirror', () => { }) it('re-reads after a folded write invalidates an in-flight document', async () => { - const slow = deferred>() + const slow = deferred>() const describeCall = vi.fn() .mockResolvedValueOnce(described([view('theme', 4), view('locale', 1)])) .mockReturnValueOnce(slow.promise) @@ -200,7 +197,7 @@ describe('SettingsDescribeMirror', () => { }) it('re-reads after a pre-answer write invalidates the in-flight document', async () => { - const slow = deferred>() + const slow = deferred>() const describeCall = vi.fn() .mockReturnValueOnce(slow.promise) .mockResolvedValueOnce(described([view('theme', 2)])) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 0f3933f61d..b6788f0dba 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsSchemaService } from '../src/client/schema.ts' @@ -18,26 +18,25 @@ const ENVELOPE = z.object({ preference: z.union(['light', 'dark', 'system']).default('system'), }).toJSON() -let rpc = 0 +/** What a Remote call answers with: no carrier envelope, and a free-form failure code. */ +type Answer = + | { ok: true; value: T } + | { ok: false; error: { code: string; message: string; details: object } } -function ok(value: T): RpcResponse { - return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } } +function ok(value: T): Answer { + return { ok: true, value } } -function rejected(): RpcResponse { - return { - rpcId: `scope-${rpc++}` as never, - result: { - ok: false, - error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } }, - }, - } +function rejected(): Answer { + return { ok: false, error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } } } } -function view(value: unknown, revision = 0): SettingsNamespaceView { +function view(value: JsonValue, revision = 0): SettingsNamespaceView { return { ns: 'ui-test', - schema: ENVELOPE, + // `toJSON()` already produced the wire envelope; its declared type is the + // schema builder's, so one cast names what the Host actually sends. + schema: ENVELOPE as unknown as JsonValue, value, applies: 'live', secrets: [], @@ -45,7 +44,7 @@ function view(value: unknown, revision = 0): SettingsNamespaceView { } } -function described(value: unknown, revision = 0) { +function described(value: JsonValue, revision = 0) { return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] }) } @@ -148,7 +147,7 @@ describe('SettingsScopeController', () => { }) it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => { - const first = deferred>() + const first = deferred>() const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4)) const mutate = vi.fn() .mockReturnValueOnce(first.promise) @@ -163,16 +162,16 @@ describe('SettingsScopeController', () => { await Promise.all([dark, light]) expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 }) - expect(mutate).toHaveBeenNthCalledWith(1, { - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'dark' }], - expectedRevision: 4, - }) - expect(mutate).toHaveBeenNthCalledWith(2, { - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'light' }], - expectedRevision: 5, - }) + expect(mutate).toHaveBeenNthCalledWith(1, + 'ui-test', + [{ op: 'set', path: ['preference'], value: 'dark' }], + 4, + ) + expect(mutate).toHaveBeenNthCalledWith(2, + 'ui-test', + [{ op: 'set', path: ['preference'], value: 'light' }], + 5, + ) }) it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { @@ -202,10 +201,11 @@ describe('SettingsScopeController', () => { initial.resolve(described({ preference: 'system' }, 1)) await loading - expect(mutate).toHaveBeenCalledWith({ - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'dark' }], - }) + expect(mutate).toHaveBeenCalledWith( + 'ui-test', + [{ op: 'set', path: ['preference'], value: 'dark' }], + undefined, + ) expect(describeCall).toHaveBeenCalledTimes(2) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 }) }) @@ -305,16 +305,16 @@ describe('SettingsScopeController', () => { await expect(scope.set('preference', 'light')).resolves.toBeUndefined() expect(mutate).toHaveBeenCalledTimes(2) - expect(mutate).toHaveBeenNthCalledWith(2, { - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'light' }], - expectedRevision: 1, - }) + expect(mutate).toHaveBeenNthCalledWith(2, + 'ui-test', + [{ op: 'set', path: ['preference'], value: 'light' }], + 1, + ) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 }) }) it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { - const first = deferred>() + const first = deferred>() const mutate = vi.fn().mockReturnValue(first.promise) const describeCall = vi.fn() const { scope } = derivedScope({ describe: describeCall, mutate }) @@ -433,11 +433,11 @@ describe('SettingsScopeController', () => { await scope.unset('preference') - expect(mutate).toHaveBeenCalledWith({ - ns: 'ui-test', - ops: [{ op: 'unset', path: ['preference'] }], - expectedRevision: 3, - }) + expect(mutate).toHaveBeenCalledWith( + 'ui-test', + [{ op: 'unset', path: ['preference'] }], + 3, + ) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 4 }) }) @@ -465,7 +465,7 @@ describe('SettingsScopeBinder.bind', () => { let theme!: SettingsScope let locale!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await() + await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, wire: wire as never }).await() expect(ctx.settingsScope.describe()).toBe(mirror) const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], @@ -493,7 +493,7 @@ describe('SettingsScopeBinder.bind', () => { ctx.provide('connection', { api: wire, isLoopback: false } as never) let scope!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await() + await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, wire: wire as never }).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 121444af93..b030523f43 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -42,22 +42,16 @@ async function bench(isLoopback = true) { revision: 0, }) const describe = vi.fn(() => Promise.resolve({ - rpcId: 'theme-describe' as never, - result: { - ok: true as const, - value: { writable: true, hasDocument: true, namespaces: [namespace()] }, - }, + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [namespace()] }, })) - const mutate = vi.fn((request: { ops: { path: string[]; value: unknown }[] }) => { - const op = request.ops[0]! + const mutate = vi.fn((_ns: string, ops: { path: string[]; value: unknown }[]) => { + const op = ops[0]! section[op.path[0]!] = op.value - return Promise.resolve({ - rpcId: 'theme-mutate' as never, - result: { ok: true as const, value: namespace() }, - }) + return Promise.resolve({ ok: true as const, value: namespace() }) }) - ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) - const events = new TestRemote(ctx) + ctx.provide('connection', { api: {}, isLoopback } as never) + const events = new TestRemote(ctx, { settings: { describe, mutate } }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, events, diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 95d113bbfd..dc37fd7c14 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -186,7 +186,6 @@ class FakeApiClient implements IApiClient { declare readonly skills: IApiClient['skills'] declare readonly agentPresets: IApiClient['agentPresets'] declare readonly settings: IApiClient['settings'] - declare readonly credentials: IApiClient['credentials'] declare readonly llm: IApiClient['llm'] readonly host: IApiClient['host'] = { diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 4c19296f65..6a0b88387f 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -42,6 +42,8 @@ export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot export { FixtureSession, TestSessions } from './sessions.ts' export { stubSettingsScope } from './settings-scope.ts' export type { StubSettingsScope } from './settings-scope.ts' +export { scriptedSettingsRemote } from './settings-remote.ts' +export type { ScriptedNamespace, ScriptedSettingsRemote } from './settings-remote.ts' export { TestWorkspaces } from './workspaces.ts' export { TestRemote } from './remote.ts' export { diff --git a/packages/test-support/client-runtime/src/remote.ts b/packages/test-support/client-runtime/src/remote.ts index d90baf7e0f..9a0249245c 100644 --- a/packages/test-support/client-runtime/src/remote.ts +++ b/packages/test-support/client-runtime/src/remote.ts @@ -4,12 +4,14 @@ import type { Context } from '@deepseek-ai/cordis' /** * Remote service test double for the forwarded-event path. Feature specs need * `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded - * Host events to reach those subscribers, but not the generated namespaces or - * the wire — so this double implements subscription plus an explicit `emit` - * driver available only on the concrete test object. + * Host events to reach those subscribers, but not the wire — so this double + * implements subscription plus an explicit `emit` driver available only on the + * concrete test object. A spec that also calls one namespace scripts it through + * the constructor rather than reaching the real Client Remote service. * - * `$mount` rejects: a spec that reaches a generated namespace through this - * double has outgrown it and needs the real Client Remote service. + * `$mount` rejects: a spec that needs a real generated contribution installed — + * codecs, descriptors, and the wire — has outgrown this double and needs the + * real Client Remote service. * * One deliberate asymmetry with production: a throwing listener propagates out * of the emit instead of being contained and logged, so a spec cannot lean on @@ -20,11 +22,22 @@ export class TestRemote { private readonly subscriptions = new Map void>>() /** - * Register the double as `ctx.remote`. + * Register the double as `ctx.remote`, plus one service per scripted + * namespace so a plugin injecting `remote.` also unparks. * @param ctx - the spec's root Context. + * @param namespaces - scripted namespace faces reached as `ctx.remote.`. */ - constructor(ctx: Context) { + constructor(ctx: Context, namespaces: Readonly> = {}) { + for (const name of Object.keys(namespaces)) { + // A namespace named after one of the double's own members would replace + // it, and `$mount`'s rejection is the contract a spec relies on. + if (name in TestRemote.prototype || name === 'subscriptions') { + throw new TypeError(`TestRemote: scripted namespace "${name}" would shadow the double's own member`) + } + } + Object.assign(this, namespaces) ctx.provide('remote', this) + for (const [name, face] of Object.entries(namespaces)) ctx.provide(`remote.${name}`, face) } /** diff --git a/packages/test-support/client-runtime/src/settings-remote.ts b/packages/test-support/client-runtime/src/settings-remote.ts new file mode 100644 index 0000000000..0a98a8f621 --- /dev/null +++ b/packages/test-support/client-runtime/src/settings-remote.ts @@ -0,0 +1,85 @@ +/** Test double for the `settings` Remote namespace a bench's plugins inject. */ +import { vi } from 'vitest' + +/** The minimum a scripted namespace view carries for the double's own bookkeeping. */ +export interface ScriptedNamespace { + /** Namespace key the write addresses. */ + ns: string +} + +/** One scripted `settings` namespace face plus the controls a bench drives it with. */ +export interface ScriptedSettingsRemote { + /** + * The namespace face handed to `TestRemote` as `settings`. A plugin injecting + * `remote.settings` unparks on it, which is what most benches need; the + * describe answer is the same one the shared mirror would read. + */ + settings: { + describe(): Promise<{ ok: true; value: { writable: boolean; hasDocument: boolean; namespaces: readonly View[] } }> + update(ns: string, patch: unknown, expectedRevision: number | undefined): Promise< + | { ok: true; value: View } + | { ok: false; error: { code: string; message: string; details: object } } + > + replace(ns: string, section: unknown, expectedRevision: number | undefined): Promise< + | { ok: true; value: View } + | { ok: false; error: { code: string; message: string; details: object } } + > + mutate(ns: string, ops: unknown, expectedRevision: number | undefined): Promise< + | { ok: true; value: View } + | { ok: false; error: { code: string; message: string; details: object } } + > + } + /** Spy behind `settings.update`, for argument assertions. */ + update: ReturnType + /** Spy behind `settings.replace`, for argument assertions. */ + replace: ReturnType + /** Spy behind `settings.mutate`, for argument assertions. */ + mutate: ReturnType + /** + * Replace what the next describe answers with, as a Host commit would. + * @param namespaces - the namespace views to serve from now on. + */ + publish(namespaces: readonly View[]): void +} + +/** + * Build a scripted `settings` Remote namespace for a bench. Each write answers + * with the addressed namespace unchanged, so a bench that only needs its + * plugins to activate scripts nothing; one asserting a write reads the + * corresponding spy or replaces the face. + * @param namespaces - namespace views the first describe answers with. + * @param options - deployment facts the describe answer reports. + * @returns the face and its controls. + */ +export function scriptedSettingsRemote( + namespaces: readonly View[] = [], + options: { writable?: boolean; hasDocument?: boolean } = {}, +): ScriptedSettingsRemote { + let served = namespaces + const writable = options.writable ?? true + const hasDocument = options.hasDocument ?? false + const answer = (ns: string) => { + const view = served.find(candidate => candidate.ns === ns) + return Promise.resolve(view === undefined + ? { + ok: false as const, + error: { code: 'settings-rejected', message: `no scripted namespace "${ns}"`, details: { ns } }, + } + : { ok: true as const, value: view }) + } + const update = vi.fn((ns: string, _patch: unknown, _expectedRevision: number | undefined) => answer(ns)) + const replace = vi.fn((ns: string, _section: unknown, _expectedRevision: number | undefined) => answer(ns)) + const mutate = vi.fn((ns: string, _ops: unknown, _expectedRevision: number | undefined) => answer(ns)) + return { + settings: { + describe: () => Promise.resolve({ ok: true as const, value: { writable, hasDocument, namespaces: served } }), + update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision), + replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision), + mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision), + }, + update, + replace, + mutate, + publish(next) { served = next }, + } +} diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index df376a83eb..1cc3e0a372 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -6,6 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { TestRemote } from '../src/remote.ts' +import { scriptedSettingsRemote } from '../src/settings-remote.ts' describe('TestRemote', () => { it('delivers a forwarded event to its subscribers and stops after disposal', async () => { @@ -40,4 +41,58 @@ describe('TestRemote', () => { await expect(remote.$mount()).rejects.toThrow('needs the real Client Remote service') await ctx.fiber.dispose() }) + + it('reaches a scripted namespace as ctx.remote. and as its own service', async () => { + const ctx = new Context() + const credentials = { describe: () => Promise.resolve({ ok: true as const, value: {} }) } + const remote = new TestRemote(ctx, { credentials }) + + expect((remote as unknown as { credentials: unknown }).credentials).toBe(credentials) + expect(ctx.get('remote.credentials')).toBe(credentials) + await ctx.fiber.dispose() + }) + + it('refuses a scripted namespace that would shadow one of its own members', async () => { + const ctx = new Context() + // Accepting this would replace the very refusal the case above pins. + expect(() => new TestRemote(ctx, { $mount: {} })).toThrow('would shadow') + expect(() => new TestRemote(ctx, { subscriptions: {} })).toThrow('would shadow') + await ctx.fiber.dispose() + }) +}) + +describe('scriptedSettingsRemote', () => { + it('serves, writes, and replaces its scripted namespace list', async () => { + const first = { ns: 'first', revision: 1 } + const second = { ns: 'second', revision: 2 } + const remote = scriptedSettingsRemote([first]) + + await expect(remote.settings.describe()).resolves.toEqual({ + ok: true, + value: { writable: true, hasDocument: false, namespaces: [first] }, + }) + await expect(remote.settings.update('first', {}, undefined)).resolves.toEqual({ ok: true, value: first }) + await expect(remote.settings.replace('missing', {}, undefined)).resolves.toMatchObject({ + ok: false, + error: { code: 'settings-rejected', details: { ns: 'missing' } }, + }) + await expect(remote.settings.mutate('first', [], undefined)).resolves.toEqual({ ok: true, value: first }) + expect(remote.update).toHaveBeenCalledWith('first', {}, undefined) + expect(remote.replace).toHaveBeenCalledWith('missing', {}, undefined) + expect(remote.mutate).toHaveBeenCalledWith('first', [], undefined) + + remote.publish([second]) + await expect(remote.settings.describe()).resolves.toEqual({ + ok: true, + value: { writable: true, hasDocument: false, namespaces: [second] }, + }) + }) + + it('reports explicit deployment facts', async () => { + const remote = scriptedSettingsRemote([], { writable: false, hasDocument: true }) + await expect(remote.settings.describe()).resolves.toEqual({ + ok: true, + value: { writable: false, hasDocument: true, namespaces: [] }, + }) + }) }) From fd7f2065b2d439f56c7f170b6bbea7258eadbee4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:01:29 +0800 Subject: [PATCH 13/18] refactor(apiproxy)!: remove settings and credentials RPCs --- .../extensions/tool-cordis/src/api-catalog.ts | 79 ++++- packages/host/apiproxy/package.json | 2 +- packages/host/apiproxy/src/api-proxy.ts | 144 +-------- .../apiproxy/src/api/credentials.schema.ts | 48 --- packages/host/apiproxy/src/api/credentials.ts | 44 --- packages/host/apiproxy/src/api/index.ts | 5 +- packages/host/apiproxy/src/api/llm.schema.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 8 - packages/host/apiproxy/src/api/rpc.schema.ts | 3 - packages/host/apiproxy/src/api/rpc.ts | 13 - .../host/apiproxy/src/api/settings.schema.ts | 69 +---- packages/host/apiproxy/src/api/settings.ts | 94 +----- packages/host/apiproxy/src/fetch/client.ts | 32 +- packages/host/apiproxy/src/fetch/handler.ts | 13 +- packages/host/apiproxy/src/index.ts | 2 - .../apiproxy/tests/api-proxy-config.spec.ts | 289 +----------------- .../apiproxy/tests/client-handler.spec.ts | 64 +--- .../host/apiproxy/tests/fetch-carrier.spec.ts | 29 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 4 - 19 files changed, 105 insertions(+), 839 deletions(-) delete mode 100644 packages/host/apiproxy/src/api/credentials.schema.ts delete mode 100644 packages/host/apiproxy/src/api/credentials.ts diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e90a33af0b..5412e025cd 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -736,6 +736,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'credentialsController', + summary: 'Host service backing the generated `ctx.remote.credentials` namespace.', + description: 'Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one.', + methods: [ + { + signature: '@Remote async describe(refs: string[]): Promise>', + description: 'Describe several references for one configuration surface. Batched because a settings page describes every reference its rows name at once, and one round trip keeps those rows from settling separately.', + parameters: [{ name: 'refs', description: 'reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`.' }], + returns: 'one view per requested name, keyed by that name.', + throws: ['TypertRemoteFailure when the request is invalid or no credential provider is mounted.'], + }, + { + signature: '@Remote async set(ref: string, value: string): Promise', + description: 'Store one value from a configuration surface. The value crosses the wire in this direction only: no read path returns it.', + parameters: [{ name: 'ref', description: 'reference name to store under.' }, { name: 'value', description: 'the non-empty secret value.' }], + throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + }, + { + signature: '@Remote async unset(ref: string): Promise', + description: 'Remove one reference from a configuration surface.', + parameters: [{ name: 'ref', description: 'reference name to remove.' }], + throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + }, + ], + }, { key: 'deepseekLlmApiExtensions', summary: 'Registry of independently owned top-level fields for official DeepSeek requests.', @@ -1870,6 +1896,41 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'settingsController', + summary: 'Host service backing the generated `ctx.remote.settings` namespace.', + description: 'Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role(\'secret\')` field cannot ride a response. Writes expose the settings service\'s merge, replacement, and path-addressed operations, and classify every provider refusal as `settings-conflict` or `settings-rejected` with the service\'s message.', + methods: [ + { + signature: '@Remote describe(): SettingsDescribeValue', + description: 'Describe every registered namespace for a configuration page: redacted layered values plus the serialized schema the page renders its form from.', + parameters: [], + returns: 'provider writability, local-document presence, and one view per namespace.', + throws: ['TypertRemoteFailure when no settings provider is mounted.'], + }, + { + signature: '@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise', + description: 'Merge a patch into one namespace\'s stored user section.', + parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'patch', description: 'fields to merge into the user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }], + returns: 'the namespace\'s redacted view after the write.', + throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + }, + { + signature: '@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise', + description: 'Replace one namespace\'s stored user section wholesale.', + parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'section', description: 'complete replacement user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }], + returns: 'the namespace\'s redacted view after the write.', + throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + }, + { + signature: '@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise', + description: 'Apply path-addressed edits to one namespace\'s user section, resolved against the section as stored rather than against whatever the caller last read, then answer with that namespace\'s new redacted view.', + parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'ops', description: 'the edits to apply, in order.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }], + returns: 'the namespace\'s redacted view after the write.', + throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + }, + ], + }, { key: 'shell', summary: 'Abstract bash execution service.', @@ -4455,7 +4516,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RpcErrorDetailsMap', - declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', + declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', }, { name: 'RpcId', @@ -4973,6 +5034,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SettingsDescribeOptions', declaration: 'export interface SettingsDescribeOptions {\n redactSecrets?: boolean;\n}', }, + { + name: 'SettingsDescribeValue', + declaration: 'export interface SettingsDescribeValue {\n writable: boolean;\n hasDocument: boolean;\n namespaces: SettingsNamespaceView[];\n}', + }, { name: 'SettingsDescriptor', declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n revision: number;\n base?: unknown;\n user?: unknown;\n applies: SettingsApplies;\n secrets?: RedactedSecret[];\n}', @@ -4981,14 +5046,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SettingsNamespace', declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;', }, + { + name: 'SettingsNamespaceView', + declaration: 'export interface SettingsNamespaceView {\n ns: string;\n schema: JsonValue;\n value: JsonValue;\n base?: JsonValue;\n user?: JsonValue;\n applies: \'live\' | \'restart\';\n secrets: SettingsSecretView[];\n revision: number;\n}', + }, { name: 'SettingsPathOp', declaration: 'export type SettingsPathOp = {\n op: \'set\';\n path: readonly string[];\n value: unknown;\n} | {\n op: \'unset\';\n path: readonly string[];\n};', }, + { + name: 'SettingsPathOpView', + declaration: 'export type SettingsPathOpView = {\n op: \'set\';\n path: string[];\n value: JsonValue;\n} | {\n op: \'unset\';\n path: string[];\n};', + }, { name: 'SettingsRegisterOptions', declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}', }, + { + name: 'SettingsSecretView', + declaration: 'export interface SettingsSecretView {\n path: string[];\n set: boolean;\n}', + }, { name: 'SettingsUpdateSource', declaration: 'export type SettingsUpdateSource = \'update\' | \'provider\';', diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 36f0ae707c..9942223158 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -51,7 +51,6 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", @@ -74,6 +73,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^" diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index fc94e58035..7aecfea3ad 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -14,10 +14,7 @@ import { InvalidPresetIdError, PresetExistsError, PresetNotWritableError, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' -import type { - ApiProxy, ConfigurableProviderView, CredentialView, - SettingsNamespaceView, -} from './api/index.ts' +import type { ApiProxy, ConfigurableProviderView } from './api/index.ts' import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { @@ -33,12 +30,6 @@ import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' // Type-only edges: resolve the command-change stream and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' -// The settings/credentials seams: brand guards run at this wire boundary; the -// service reads stay optional (`ctx.get`) so a composition without either -// provider still serves every other domain. -import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' -import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings' -import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' @@ -187,79 +178,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return defaults.openPath !== undefined || canOpenNativePath() } - /** Missing-service report shared by the credentials domain. */ - function credentialsAbsent(): RpcError { - return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} } - } - /** Map one redacted settings descriptor to its wire view. */ - function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView { - return { - ns: String(descriptor.ns), - schema: descriptor.schema, - value: descriptor.value, - ...descriptor.base === undefined ? {} : { base: descriptor.base }, - ...descriptor.user === undefined ? {} : { user: descriptor.user }, - applies: descriptor.applies, - secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })), - revision: descriptor.revision, - } - } - - /** - * Run one settings write (merge or wholesale replace) and acknowledge with - * the namespace's new redacted view. Every seam refusal — unknown or invalid - * namespace, read-only provider, schema validation, storage — becomes one - * `settings-rejected` carrying the seam's own message. - */ - async function settingsWrite( - request: RpcRequest, - ns: string, - mode: 'update' | 'replace' | 'mutate', - section: object, - expectedRevision?: number, - ): Promise> { - const settings = ctx.get('settings') - if (settings === undefined) return err(request, settingsAbsent()) - const rejected = (error: unknown): RpcResponse => { - // A stale writer is its own outcome, not a malformed request: the client - // must re-read and re-apply rather than treat the write as invalid. - if (error instanceof SettingsConflictError) { - return err(request, { - code: 'settings-conflict', - message: error.message, - details: { ns, expected: error.expected, actual: error.actual }, - }) - } - return err(request, { - code: 'settings-rejected', - message: error instanceof Error ? error.message : String(error), - details: { ns }, - }) - } - let branded: SettingsNamespace - try { - branded = settingsNamespace(ns) - } catch (error: unknown) { - // A malformed name can address no registration, so it fails exactly as - // an unregistered one does. - return rejected(error) - } - try { - if (mode === 'update') await settings.update(branded, section, expectedRevision) - else if (mode === 'replace') await settings.replace(branded, section, expectedRevision) - else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision) - } catch (error: unknown) { - return rejected(error) - } - const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded) - if (descriptor === undefined) { - // The write committed but the namespace vanished before this read: only - // a concurrent registrant disposal can produce it. - return err(request, { code: 'internal', message: `settings namespace "${ns}" was disposed after the ${mode}`, details: {} }) - } - return ok(request, namespaceView(descriptor)) - } return { host: { @@ -383,15 +302,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, settings: { - describe(request) { - const settings = ctx.get('settings') - if (settings === undefined) return Promise.resolve(err(request, settingsAbsent())) - return Promise.resolve(ok(request, { - writable: settings.writable, - hasDocument: settings.documentPath !== undefined, - namespaces: settings.describe({ redactSecrets: true }).map(namespaceView), - })) - }, async openDocument(request, signal) { const settings = ctx.get('settings') if (settings === undefined) return err(request, settingsAbsent()) @@ -435,58 +345,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return openTextFile(request, path, signal) }, - update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision), - replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision), - mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision), - }, - - credentials: { - async describe(request) { - const credentials = ctx.get('credentials') - if (credentials === undefined) return err(request, credentialsAbsent()) - const entries = await Promise.all(request.payload.refs.map(async (ref) => { - const info = await credentials.describe(credentialRef(ref)) - const view: CredentialView = { - configured: info.configured, - ...info.source === undefined ? {} : { source: info.source }, - writable: info.writable, - } - return [ref, view] as const - })) - return ok(request, { credentials: Object.fromEntries(entries) }) - }, - - async set(request) { - const credentials = ctx.get('credentials') - if (credentials === undefined) return err(request, credentialsAbsent()) - const { ref, value } = request.payload - try { - await credentials.set(credentialRef(ref), value) - } catch (error: unknown) { - return err(request, { - code: 'credential-rejected', - message: error instanceof Error ? error.message : String(error), - details: { ref }, - }) - } - return ok(request, {}) - }, - - async unset(request) { - const credentials = ctx.get('credentials') - if (credentials === undefined) return err(request, credentialsAbsent()) - const { ref } = request.payload - try { - await credentials.unset(credentialRef(ref)) - } catch (error: unknown) { - return err(request, { - code: 'credential-rejected', - message: error instanceof Error ? error.message : String(error), - details: { ref }, - }) - } - return ok(request, {}) - }, }, llm: { diff --git a/packages/host/apiproxy/src/api/credentials.schema.ts b/packages/host/apiproxy/src/api/credentials.schema.ts deleted file mode 100644 index b0ce3fe01b..0000000000 --- a/packages/host/apiproxy/src/api/credentials.schema.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * credentials domain zod schemas (names derived from map keys: - * credentialsDescribeRequestSchema / credentialsDescribeValueSchema / …). - * The reference-name pattern mirrors the seam's `credentialRef` guard so an - * invalid name fails as `bad-request` before reaching the service. - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import type { CredentialView } from './credentials.ts' - -/** POSIX-portable environment-variable name (the seam's `credentialRef` pattern). */ -export const credentialRefNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/) - -/** CredentialView entry of credentials.describe. */ -export const credentialViewSchema = z.object({ - configured: z.boolean(), - source: z.string().optional(), - writable: z.boolean(), -}) satisfies z.ZodType> - -/** credentials.describe request payload. */ -export const credentialsDescribeRequestSchema = z.object({ - refs: z.array(credentialRefNameSchema).max(64), -}) satisfies z.ZodType>> - -/** credentials.describe response value. */ -export const credentialsDescribeValueSchema = z.object({ - credentials: z.record(z.string(), credentialViewSchema), -}) satisfies z.ZodType>> - -/** credentials.set request payload: the one direction a value crosses this wire. */ -export const credentialsSetRequestSchema = z.object({ - ref: credentialRefNameSchema, - value: z.string().min(1), -}) satisfies z.ZodType>> - -/** credentials.set response value. */ -export const credentialsSetValueSchema = z.object({}) satisfies z.ZodType>> - -/** credentials.unset request payload. */ -export const credentialsUnsetRequestSchema = z.object({ - ref: credentialRefNameSchema, -}) satisfies z.ZodType>> - -/** credentials.unset response value. */ -export const credentialsUnsetValueSchema = z.object({}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/credentials.ts b/packages/host/apiproxy/src/api/credentials.ts deleted file mode 100644 index b5b59ca059..0000000000 --- a/packages/host/apiproxy/src/api/credentials.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * credentials domain contract: the web face of the credential-reference seam - * (`ctx.credentials`). Reads are structurally value-free — a credential view - * carries configured/source/writable and has no slot for the value — and the - * value crosses the wire in exactly one direction, inside `credentials.set`. - * There is no enumeration method by design: clients learn which references - * exist from settings schemas and values (`apiKeyEnv` fields). - */ - -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** Wire view of one credential reference's state. */ -export interface CredentialView { - /** Whether any layer currently supplies a non-empty value. */ - configured: boolean - /** Winning layer when configured (`env`, `file`, …); provider vocabulary. */ - source?: string - /** Whether `credentials.set`/`credentials.unset` can affect this reference. */ - writable: boolean -} - -/** Credentials-domain unary methods (the map keys credentials.* of RpcMethodMap). */ -export interface CredentialsApi { - /** - * Describe the named references (batch): configured state, winning source, - * and writability — never values. An invalid reference name is a - * `bad-request`; an unknown-but-valid one describes as unconfigured. - */ - describe(request: RpcRequest<{ refs: string[] }>): Promise }>> - - /** - * Store one credential value in the writable layer. Rejected with - * `credential-rejected` while a read-only layer (the live environment) - * shadows the reference — the write would otherwise appear to succeed while - * resolution keeps returning the shadowing value. - */ - set(request: RpcRequest<{ ref: string; value: string }>): Promise> - - /** - * Remove one credential from the writable layer; same shadowing rejection - * as `set`. Unsetting an absent reference succeeds (idempotent). - */ - unset(request: RpcRequest<{ ref: string }>): Promise> -} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index b151677137..8e33371141 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -8,7 +8,6 @@ import type { HostApi } from './host.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SettingsApi } from './settings.ts' -import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { DownloadsApi } from './downloads.ts' @@ -18,7 +17,6 @@ export interface ApiProxy { skills: SkillsApi agentPresets: AgentPresetsApi settings: SettingsApi - credentials: CredentialsApi llm: LlmApi /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ downloads: DownloadsApi @@ -32,8 +30,7 @@ export type { export type { HostApi } from './host.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi } from './agent-presets.ts' -export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' -export type { CredentialsApi, CredentialView } from './credentials.ts' +export type { SettingsApi } from './settings.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' export type { DownloadsApi } from './downloads.ts' diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index ff7c63a822..2c1619c8df 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -103,7 +103,7 @@ export const llmDiscoverModelsRequestSchema = z.object({ api: z.string().min(1).optional(), // Write-only at the host: used for this one interrogation, never stored and // never returned. It does ride the client's outgoing envelope like every - // other secret-bearing payload (`credentials.set`, `settings.update`), which + // other secret-bearing payload (`settings/update`), which // `subscribeEnvelopes()` observers can see — redacting that tap is a // configuration-plane-wide change, not this method's to make alone. apiKey: z.string().min(1).optional(), diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 2590b4cec7..99c8c7e580 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -7,7 +7,6 @@ import type { HostApi } from './host.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SettingsApi } from './settings.ts' -import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { RpcResponse } from './rpc.ts' @@ -21,14 +20,7 @@ export interface RpcMethodMap { 'host.openPath': HostApi['openPath'] 'skill.list': SkillsApi['list'] 'agentPreset.openDocument': AgentPresetsApi['openDocument'] - 'settings.describe': SettingsApi['describe'] 'settings.openDocument': SettingsApi['openDocument'] - 'settings.update': SettingsApi['update'] - 'settings.replace': SettingsApi['replace'] - 'settings.mutate': SettingsApi['mutate'] - 'credentials.describe': CredentialsApi['describe'] - 'credentials.set': CredentialsApi['set'] - 'credentials.unset': CredentialsApi['unset'] 'llm.providers': LlmApi['providers'] 'llm.models': LlmApi['models'] 'llm.discoverModels': LlmApi['discoverModels'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 57ca87bcf3..434bbb24dc 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -41,9 +41,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), - z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), - z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 7448ef669c..0981944824 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -36,19 +36,6 @@ export interface RpcErrorDetailsMap { 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } - /** - * A settings write was refused (schema validation, unknown namespace, - * read-only provider, or storage failure); the message is the seam's text. - */ - 'settings-rejected': { ns: string } - /** - * A settings write carried an `expectedRevision` the namespace has already - * moved past: another writer (tab, editor, or an external file edit) landed - * first. The details carry both revisions so a client can re-read and retry. - */ - 'settings-conflict': { ns: string; expected: number; actual: number } - /** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */ - 'credential-rejected': { ref: string } /** * Interrogating a draft provider endpoint did not produce a model listing: * no adapter family serves the namespace, the protocol has no listing this diff --git a/packages/host/apiproxy/src/api/settings.schema.ts b/packages/host/apiproxy/src/api/settings.schema.ts index 19fa2c012e..fcfcb75cea 100644 --- a/packages/host/apiproxy/src/api/settings.schema.ts +++ b/packages/host/apiproxy/src/api/settings.schema.ts @@ -1,40 +1,11 @@ /** - * settings domain zod schemas (names derived from map keys: settingsDescribeRequestSchema / - * settingsDescribeValueSchema / settingsUpdate* / settingsReplace*). + * settings domain zod schemas (names derived from map keys: + * settingsOpenDocumentRequestSchema / settingsOpenDocumentValueSchema). */ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import type { SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' - -/** One redacted secret slot. */ -export const settingsSecretViewSchema = z.object({ - path: z.array(z.string()), - set: z.boolean(), -}) satisfies z.ZodType> - -/** SettingsNamespaceView row of settings.describe and the write responses. */ -export const settingsNamespaceViewSchema = z.object({ - ns: z.string().min(1), - schema: z.unknown(), - value: z.unknown(), - base: z.unknown().optional(), - user: z.unknown().optional(), - applies: z.union([z.literal('live'), z.literal('restart')]), - secrets: z.array(settingsSecretViewSchema), - revision: z.number(), -}) satisfies z.ZodType> - -/** settings.describe request payload. */ -export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType>> - -/** settings.describe response value. */ -export const settingsDescribeValueSchema = z.object({ - writable: z.boolean(), - hasDocument: z.boolean(), - namespaces: z.array(settingsNamespaceViewSchema), -}) satisfies z.ZodType>> /** settings.openDocument request payload. */ export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType>> @@ -43,39 +14,3 @@ export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodTyp export const settingsOpenDocumentValueSchema = z.object({ opened: z.literal(true), }) satisfies z.ZodType>> - -/** settings.update request payload. */ -export const settingsUpdateRequestSchema = z.object({ - ns: z.string().min(1), - patch: z.record(z.string(), z.unknown()), - expectedRevision: z.number().optional(), -}) satisfies z.ZodType>> - -/** settings.update response value: the namespace's new redacted view. */ -export const settingsUpdateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType>> - -/** settings.replace request payload. */ -export const settingsReplaceRequestSchema = z.object({ - ns: z.string().min(1), - section: z.record(z.string(), z.unknown()), - expectedRevision: z.number().optional(), -}) satisfies z.ZodType>> - -/** One path-addressed edit of settings.mutate. */ -export const settingsPathOpSchema = z.discriminatedUnion('op', [ - z.object({ op: z.literal('set'), path: z.array(z.string()), value: z.unknown() }), - z.object({ op: z.literal('unset'), path: z.array(z.string()) }), -]) as unknown as z.ZodType> - -/** settings.mutate request payload. */ -export const settingsMutateRequestSchema = z.object({ - ns: z.string().min(1), - ops: z.array(settingsPathOpSchema), - expectedRevision: z.number().optional(), -}) satisfies z.ZodType>> - -/** settings.mutate response value: the namespace's new redacted view. */ -export const settingsMutateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType>> - -/** settings.replace response value. */ -export const settingsReplaceValueSchema = settingsNamespaceViewSchema satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/settings.ts b/packages/host/apiproxy/src/api/settings.ts index ec7017eef7..07fc0e72be 100644 --- a/packages/host/apiproxy/src/api/settings.ts +++ b/packages/host/apiproxy/src/api/settings.ts @@ -1,69 +1,15 @@ /** - * settings domain contract: the web face of the user-settings seam - * (`ctx.settings`). Every payload that leaves this domain is redacted by the - * seam (`describe({ redactSecrets: true })` semantics): `role('secret')` - * fields never ride a response in any layer, and the `secrets` slot list is - * how a form learns a write-only field exists and whether it is configured. + * settings domain contract: what remains of the web face of the user-settings + * seam (`ctx.settings`) once the redacted read and the path-addressed write + * moved to the `settings` Remote namespace. Only the local-document handoff + * stays here, because opening a Host file is a platform action rather than a + * settings read. */ import type { RpcRequest, RpcResponse } from './rpc.ts' -/** One schema-declared secret slot inside a redacted namespace value. */ -export interface SettingsSecretView { - /** Path from the section root to the removed field. */ - path: string[] - /** Whether the slot currently holds a value (the value itself never rides). */ - set: boolean -} - -/** Wire view of one registered settings namespace. */ -export interface SettingsNamespaceView { - /** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */ - ns: string - /** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */ - schema: unknown - /** Redacted resolved value (schema defaults → composition base → user layer). */ - value: unknown - /** Redacted composition base layer, when the registrant declared one. */ - base?: unknown - /** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */ - user?: unknown - /** When the owner applies changes. */ - applies: 'live' | 'restart' - /** Every schema-declared secret slot with its configured state. */ - secrets: SettingsSecretView[] - /** - * Monotonic revision of the raw user section this view was read at. Send it - * back as `expectedRevision` on a write so a stale editor is refused rather - * than silently overwriting a concurrent change. - */ - revision: number -} - -/** - * One path-addressed edit carried by `settings.mutate`. `set` writes the - * value at the path (creating intermediate objects); `unset` removes it. The - * empty path addresses the section root. - */ -export type SettingsPathOpView = - | { op: 'set'; path: string[]; value: unknown } - | { op: 'unset'; path: string[] } - /** Settings-domain unary methods (the map keys settings.* of RpcMethodMap). */ export interface SettingsApi { - /** - * Describe every registered namespace: redacted layered values plus the - * serialized schema a client renders its form from. `hasDocument` reports - * whether a file-backed provider owns a local document without exposing its - * Host path. Connection requires the browser session used by every Host API - * method; `writable: false` tells the client to disable every write control. - */ - describe(request: RpcRequest<{}>): Promise> - /** * Materialize the configured local document when absent and ask the Host to * hand it to the platform text-document opener. macOS forces a text editor; @@ -73,34 +19,4 @@ export interface SettingsApi { openDocument( request: RpcRequest<{}>, signal: AbortSignal, ): Promise> - - /** - * Merge a patch into one namespace's user layer (validate → persist → - * commit). Secret-role fields may be INCLUDED in the patch (write-only - * direction); a form that leaves a secret untouched simply omits it and the - * merge preserves the stored value. Responds with the namespace's new - * redacted view; a schema or storage rejection is `settings-rejected`. - */ - update(request: RpcRequest<{ ns: string; patch: object; expectedRevision?: number }>): Promise> - - /** - * Replace one namespace's user section wholesale — the removal/reset path a - * merge cannot express (`section: {}` resets to composition defaults). Keys - * absent from `section` are dropped, secrets included: a client must first - * fold the descriptor's `user` layer (and re-supply any secret it wants to - * keep) or accept the reset. - */ - replace(request: RpcRequest<{ ns: string; section: object; expectedRevision?: number }>): Promise> - - /** - * Apply path-addressed edits to one namespace's user section, resolved - * against the section as stored — NOT against whatever the caller last - * read. This is the removal path for any client holding the redacted - * descriptor: it names the field it means, so a secret the wire never - * returned cannot be deleted as a side effect. `replace` remains the - * deliberate wholesale reset. - */ - mutate( - request: RpcRequest<{ ns: string; ops: SettingsPathOpView[]; expectedRevision?: number }>, - ): Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 4a567f8638..c33313ece2 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -20,12 +20,8 @@ import { agentPresetOpenDocumentValueSchema, } from '../api/agent-presets.schema.ts' import { - settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema, - settingsReplaceValueSchema, settingsUpdateValueSchema, + settingsOpenDocumentValueSchema, } from '../api/settings.schema.ts' -import { - credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema, -} from '../api/credentials.schema.ts' import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' /** @@ -52,16 +48,7 @@ export interface IApiClient { openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise>> } settings: { - describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise>> openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise>> - update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise>> - replace(payload: RequestPayload<'settings.replace'>, signal?: AbortSignal): Promise>> - mutate(payload: RequestPayload<'settings.mutate'>, signal?: AbortSignal): Promise>> - } - credentials: { - describe(payload: RequestPayload<'credentials.describe'>, signal?: AbortSignal): Promise>> - set(payload: RequestPayload<'credentials.set'>, signal?: AbortSignal): Promise>> - unset(payload: RequestPayload<'credentials.unset'>, signal?: AbortSignal): Promise>> } llm: { providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise>> @@ -79,14 +66,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('settings.describe', payload, signal), openDocument: (payload, signal) => this.callUnary('settings.openDocument', payload, signal), - update: (payload, signal) => this.callUnary('settings.update', payload, signal), - replace: (payload, signal) => this.callUnary('settings.replace', payload, signal), - mutate: (payload, signal) => this.callUnary('settings.mutate', payload, signal), - } - - readonly credentials: IApiClient['credentials'] = { - describe: (payload, signal) => this.callUnary('credentials.describe', payload, signal), - set: (payload, signal) => this.callUnary('credentials.set', payload, signal), - unset: (payload, signal) => this.callUnary('credentials.unset', payload, signal), } readonly llm: IApiClient['llm'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d4f3ea58ed..d5fb1d0e29 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -22,12 +22,8 @@ import { agentPresetOpenDocumentRequestSchema, } from '../api/agent-presets.schema.ts' import { - settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema, - settingsReplaceRequestSchema, settingsUpdateRequestSchema, + settingsOpenDocumentRequestSchema, } from '../api/settings.schema.ts' -import { - credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema, -} from '../api/credentials.schema.ts' import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' /** @@ -51,14 +47,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) }, - 'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) }, 'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) }, - 'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) }, - 'settings.replace': { schema: settingsReplaceRequestSchema, invoke: (api, r) => api.settings.replace(r) }, - 'settings.mutate': { schema: settingsMutateRequestSchema, invoke: (api, r) => api.settings.mutate(r) }, - 'credentials.describe': { schema: credentialsDescribeRequestSchema, invoke: (api, r) => api.credentials.describe(r) }, - 'credentials.set': { schema: credentialsSetRequestSchema, invoke: (api, r) => api.credentials.set(r) }, - 'credentials.unset': { schema: credentialsUnsetRequestSchema, invoke: (api, r) => api.credentials.unset(r) }, 'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) }, 'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) }, 'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 98a0e091c8..e8816f99ce 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -77,7 +77,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly skills: ApiProxy['skills'] readonly agentPresets: ApiProxy['agentPresets'] readonly settings: ApiProxy['settings'] - readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] readonly downloads: ApiProxy['downloads'] @@ -95,7 +94,6 @@ export class ApiProxyService extends Service implements ApiProxy { this.skills = api.skills this.agentPresets = api.agentPresets this.settings = api.settings - this.credentials = api.credentials this.llm = api.llm this.downloads = api.downloads } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 9eb2b09c85..8152fa262e 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -1,8 +1,7 @@ /** - * Settings/credentials/llm RPC domains and their owner events over - * createApiProxy: layered redacted describe, write-path rejection mapping, - * value-free credential views, the directory/live-route merge, and the three - * invalidation frames (settings/credentials/models changed). + * Settings and llm RPC domains and their owner events over createApiProxy: + * layered redacted describe, write-path rejection mapping, the + * directory/live-route merge, and the settings and model invalidation frames. */ import { describe, expect, it, vi } from 'vitest' @@ -240,18 +239,6 @@ async function captureSettingsUpdates( } } -/** Observe credential commits while one API operation runs. */ -async function captureCredentialUpdates(ctx: Context, run: () => Promise): Promise { - const updates: CredentialRef[] = [] - const dispose = ctx.on('credentials/reference-updated', (ref) => { updates.push(ref) }) - try { - await run() - return updates - } finally { - dispose() - } -} - /** Count model-adapter topology commits while one API operation runs. */ async function countAdapterUpdates(ctx: Context, run: () => Promise): Promise { let updates = 0 @@ -273,32 +260,11 @@ describe('settings domain', () => { it('reports an actionable error when no settings provider is mounted', async () => { const ctx = await harness({ settings: false }) const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.settings.describe(request({}))) + const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal)) expect(error.code).toBe('internal') expect(error.message).toContain('dsh-settings-file') }) - it('describes layered redacted namespaces with their secret slots', async () => { - const ctx = await harness({ settings: { - doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } }, - documentPath: '/tmp/custom-settings.yaml', - } }) - ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) - const api = createApiProxy(ctx, DEFAULTS) - const value = expectOk(await api.settings.describe(request({}))) - expect(value.writable).toBe(true) - expect(value.hasDocument).toBe(true) - expect(value.namespaces).toHaveLength(1) - const view = value.namespaces[0]! - expect(view.ns).toBe('llm-deepseek') - expect(view.applies).toBe('live') - expect((view.schema as { refs?: unknown }).refs).toBeDefined() - expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://user' }) - expect(view.base).toEqual({ baseURL: 'https://base' }) - expect(view.user).toEqual({ baseURL: 'https://user' }) - expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }]) - expect(JSON.stringify(value)).not.toContain('user-secret') - }) it('opens the provider-resolved document without accepting a browser path', async () => { const ctx = await harness({ settings: { @@ -322,7 +288,7 @@ describe('settings domain', () => { it('refuses to open settings when the provider has no local document', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) - expect(expectOk(await api.settings.describe(request({}))).hasDocument).toBe(false) + expect(ctx.settings.documentPath).toBeUndefined() const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal)) expect(error.code).toBe('internal') expect(error.message).toContain('no local document') @@ -356,141 +322,9 @@ describe('settings domain', () => { expect(opened).toEqual([]) }) - it('serves every registered namespace, including one this repository never named', async () => { - // Registering IS the exposure: a plugin distributed outside this - // repository configures itself from the browser without a change here. - // The plane stays browser-authenticated and secret-redacted, and which - // surface renders a namespace is the browser's decision, not this proxy's. - const ctx = await harness() - ctx.settings.register(NS, AdapterConfig) - ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) - ctx.settings.register(settingsNamespace('permission'), z.object({ - defaultPreset: z.union(['read-only', 'workspace-write']).required(), - }), { - base: { defaultPreset: 'read-only' }, - }) - ctx.settings.register(settingsNamespace('ui-theme'), z.object({ - preference: z.union(['light', 'dark', 'system']).default('system'), - })) - ctx.settings.register(settingsNamespace('locale'), z.object({ - preference: z.union(['zh', 'en']).required(false), - })) - ctx.settings.register(settingsNamespace('ui-conversation'), z.object({ - busyEnter: z.union(['queue', 'steer']).default('queue'), - })) - ctx.settings.register(settingsNamespace('shell'), z.object({ - timeoutMs: z.number().default(120_000), - })) - ctx.settings.register(settingsNamespace('agent-loop'), z.object({ - maxParallelToolCalls: z.number().default(10), - })) - ctx.settings.register(settingsNamespace('web-search-deepseek'), z.object({ - baseURL: z.string(), - })) - const api = createApiProxy(ctx, DEFAULTS) - const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual([ - 'llm-deepseek', 'some-other-plugin', 'permission', 'ui-theme', 'locale', - 'ui-conversation', 'shell', 'agent-loop', 'web-search-deepseek', - ]) - const permission = expectOk(await api.settings.mutate(request({ - ns: 'permission', - ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], - }))) - expect(permission.value).toEqual({ defaultPreset: 'workspace-write' }) - const theme = expectOk(await api.settings.mutate(request({ - ns: 'ui-theme', - ops: [{ op: 'set', path: ['preference'], value: 'dark' }], - }))) - expect(theme.value).toEqual({ preference: 'dark' }) - const locale = expectOk(await api.settings.mutate(request({ - ns: 'locale', - ops: [{ op: 'set', path: ['preference'], value: 'en' }], - }))) - expect(locale.value).toEqual({ preference: 'en' }) - const conversation = expectOk(await api.settings.mutate(request({ - ns: 'ui-conversation', - ops: [{ op: 'set', path: ['busyEnter'], value: 'steer' }], - }))) - expect(conversation.value).toEqual({ busyEnter: 'steer' }) - const bash = expectOk(await api.settings.mutate(request({ - ns: 'shell', - ops: [{ op: 'set', path: ['timeoutMs'], value: 5_000 }], - }))) - expect(bash.value).toEqual({ timeoutMs: 5_000 }) - const agentLoop = expectOk(await api.settings.mutate(request({ - ns: 'agent-loop', - ops: [{ op: 'set', path: ['maxParallelToolCalls'], value: 2 }], - }))) - expect(agentLoop.value).toEqual({ maxParallelToolCalls: 2 }) - const webSearch = expectOk(await api.settings.mutate(request({ - ns: 'web-search-deepseek', - ops: [{ op: 'set', path: ['baseURL'], value: 'https://search.test/v1' }], - }))) - expect(webSearch.value).toEqual({ baseURL: 'https://search.test/v1' }) - const other = expectOk(await api.settings.update(request({ - ns: 'some-other-plugin', - patch: { secretPath: '/etc/shadow' }, - }))) - expect(other.value).toEqual({ secretPath: '/etc/shadow' }) - expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value) - .toEqual({ secretPath: '/etc/shadow' }) - }) - it('serves product preference namespaces without invalidating the model catalog', async () => { - const ctx = await harness() - ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() })) - ctx.settings.register(settingsNamespace('ui-theme'), z.object({ - preference: z.union(['light', 'dark', 'system']).default('system'), - })) - const api = createApiProxy(ctx, DEFAULTS) - expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) - .toEqual(['ui-onboarding', 'ui-theme']) - const updates = await captureSettingsUpdates(ctx, async () => { - expectOk(await api.settings.mutate(request({ - ns: 'ui-onboarding', - ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }], - }))) - expectOk(await api.settings.mutate(request({ - ns: 'ui-theme', - ops: [{ op: 'set', path: ['preference'], value: 'dark' }], - }))) - }) - expect(updates).toEqual([ - expectedSettingsUpdate('ui-onboarding'), - expectedSettingsUpdate('ui-theme'), - ]) - }) - - it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => { - const ctx = await harness() - ctx.settings.register(settingsNamespace('agent-presets'), z.object({ default: z.string() })) - const api = createApiProxy(ctx, DEFAULTS) - - expectOk(await api.settings.update(request({ ns: 'agent-presets', patch: { default: 'minimal' } }))) - - // Both browser surfaces that offer the choice — the General row and the - // management section — write the default through `settings.update`, so a - // namespace outside this boundary makes the picker move and then silently - // forget, which is worse than refusing the control. - expect(ctx.settings.describe().find(view => String(view.ns) === 'agent-presets')?.value) - .toEqual({ default: 'minimal' }) - }) - - it('keeps serving a provider namespace whose directory entry is gone', async () => { - // The configurable-provider directory says what the Models page can offer, - // not what a user may configure: a dormant route's stored section is still - // theirs to edit, and losing the entry must not strand it. - const ctx = await harness({ configurableProviders: false }) - ctx.settings.register(NS, AdapterConfig) - const api = createApiProxy(ctx, DEFAULTS) - expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) - .toEqual(['llm-deepseek']) - expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).value) - .toMatchObject({ baseURL: 'https://x' }) - }) it('forwards a provider settings change for model-catalog consumers', async () => { // Editing `models` changes no route, so llm/adapters-updated never fires @@ -500,13 +334,12 @@ describe('settings domain', () => { // overridden. const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) - const api = createApiProxy(ctx, DEFAULTS) const updates = await captureSettingsUpdates(ctx, async () => { - await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } })) + await ctx.settings.update(settingsNamespace('llm-deepseek'), { baseURL: 'https://base' }) }) expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')]) // The resolved value never moved: base already said https://base. - expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value) + expect(ctx.settings.describe().find(view => String(view.ns) === 'llm-deepseek')?.value) .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) }) @@ -538,116 +371,11 @@ describe('settings domain', () => { expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')]) }) - it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { - const ctx = await harness() - ctx.settings.register(NS, AdapterConfig) - const api = createApiProxy(ctx, DEFAULTS) - const opened = expectOk(await api.settings.describe(request({}))).namespaces[0]!.revision - expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://first' }, expectedRevision: opened }))) - .revision).toBe(opened + 1) - const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://second' }, expectedRevision: opened }))) - expect(error.code).toBe('settings-conflict') - expect(error.details).toEqual({ ns: 'llm-deepseek', expected: opened, actual: opened + 1 }) - // The refused write changed nothing. - expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.user).toEqual({ baseURL: 'https://first' }) - }) - it('updates the user layer, answers with the new redacted view, and broadcasts the frame', async () => { - const ctx = await harness() - ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) - const api = createApiProxy(ctx, DEFAULTS) - const updates = await captureSettingsUpdates(ctx, async () => { - const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } }))) - expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' }) - expect(view.user).toEqual({ baseURL: 'https://next' }) - expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }]) - expect(JSON.stringify(view)).not.toContain('sk-new') - }) - expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')]) - }) - it('replace resets the user layer wholesale', async () => { - const ctx = await harness({ settings: { doc: { 'llm-deepseek': { baseURL: 'https://user' } } } }) - ctx.settings.register(NS, AdapterConfig) - const api = createApiProxy(ctx, DEFAULTS) - const view = expectOk(await api.settings.replace(request({ ns: 'llm-deepseek', section: {} }))) - expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY' }) - expect(view.user).toEqual({}) - }) - it.each([ - ['an invalid namespace name', 'Not A Namespace', {}], - ['a schema-invalid patch', 'llm-deepseek', { baseURL: 42 }], - ])('rejects %s as settings-rejected', async (_case, ns, patch) => { - const ctx = await harness() - ctx.settings.register(NS, AdapterConfig) - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.settings.update(request({ ns, patch }))) - expect(error.code).toBe('settings-rejected') - expect(error.details).toEqual({ ns }) - }) - it('answers an unregistered namespace as the seam does, and a malformed one alike', async () => { - // A name no registration answers and a name no registration could answer - // fold into the same rejection: the proxy adds no boundary of its own, so - // the seam's own refusal is the whole answer. - const ctx = await harness() - ctx.settings.register(NS, AdapterConfig) - const api = createApiProxy(ctx, DEFAULTS) - const unknown = expectErr(await api.settings.update(request({ ns: 'unknown-ns', patch: {} }))) - const malformed = expectErr(await api.settings.update(request({ ns: 'Not A Namespace', patch: {} }))) - expect(unknown.code).toBe('settings-rejected') - expect(unknown.message).toContain('is not registered') - expect(malformed.code).toBe(unknown.code) - }) - it('maps a read-only provider refusal onto the same rejection', async () => { - const ctx = await harness({ settings: { readOnly: true } }) - ctx.settings.register(NS, AdapterConfig) - const api = createApiProxy(ctx, DEFAULTS) - const value = expectOk(await api.settings.describe(request({}))) - expect(value.writable).toBe(false) - const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: {} }))) - expect(error.code).toBe('settings-rejected') - expect(error.message).toContain('read-only') - }) -}) - -describe('credentials domain', () => { - it('reports an actionable error when no credential provider is mounted', async () => { - const ctx = await harness({ credentials: false }) - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.credentials.describe(request({ refs: ['A'] }))) - expect(error.code).toBe('internal') - expect(error.message).toContain('dsh-credentials-local') - }) - - it('describes value-free views and flips state through set/unset with frames', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) - expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } }) - const updates = await captureCredentialUpdates(ctx, async () => { - expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' }))) - const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) - expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } }) - expect(JSON.stringify(after)).not.toContain('sk-secret') - expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' }))) - }) - expect(updates).toEqual(['OPENAI_API_KEY', 'OPENAI_API_KEY']) - }) - - it('maps a shadowed write onto credential-rejected for set and unset alike', async () => { - const ctx = await harness({ credentials: { shadowed: ['DEEPSEEK_API_KEY'] } }) - const api = createApiProxy(ctx, DEFAULTS) - const described = expectOk(await api.credentials.describe(request({ refs: ['DEEPSEEK_API_KEY'] }))) - expect(described.credentials['DEEPSEEK_API_KEY']).toEqual({ configured: true, source: 'env', writable: false }) - const setError = expectErr(await api.credentials.set(request({ ref: 'DEEPSEEK_API_KEY', value: 'x' }))) - expect(setError.code).toBe('credential-rejected') - expect(setError.details).toEqual({ ref: 'DEEPSEEK_API_KEY' }) - const unsetError = expectErr(await api.credentials.unset(request({ ref: 'DEEPSEEK_API_KEY' }))) - expect(unsetError.code).toBe('credential-rejected') - }) }) describe('llm domain', () => { @@ -734,8 +462,7 @@ describe('llm.discoverModels', () => { }]) // Interrogating a draft is a read: no namespace gained a section, and no // credential reference was written. - expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) - .not.toContain('llm-pi-ai') + expect(ctx.settings.describe().map(view => String(view.ns))).not.toContain('llm-pi-ai') }) it('carries the route being edited so an adapter can answer from its own registry', async () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 45b6d153ce..b570fbeec7 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -19,7 +19,6 @@ function scriptedApi(overrides: { skills?: Partial agentPresets?: Partial settings?: Partial - credentials?: Partial llm?: Partial } = {}): ApiProxy { const err = (r: RpcRequest): Promise> => @@ -38,19 +37,9 @@ function scriptedApi(overrides: { ...overrides.agentPresets, }, settings: { - describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }), openDocument: r => ok(r, { opened: true as const }), - update: err, - replace: err, - mutate: err, ...overrides.settings, }, - credentials: { - describe: r => ok(r, { credentials: {} }), - set: err, - unset: err, - ...overrides.credentials, - }, llm: { providers: r => ok(r, { providers: [] }), models: r => ok(r, { @@ -291,18 +280,9 @@ describe('envelope tap', () => { }) describe('config unary surface', () => { - it('round-trips every settings/credentials/llm method with its own payload and value shape', async () => { + it('round-trips every settings/llm method with its own payload and value shape', async () => { const seen: { method: string; payload: unknown }[] = [] const record = recorderInto(seen) - const view = { - ns: 'llm-deepseek', - schema: { uid: 1, refs: { 1: { type: 'object' } } }, - value: { baseURL: 'https://next' }, - user: { baseURL: 'https://next' }, - applies: 'live' as const, - secrets: [{ path: ['apiKey'], set: true }], - revision: 0, - } const providerRow = { provider: 'openai', displayName: 'openai', @@ -313,16 +293,7 @@ describe('config unary surface', () => { const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] } const api = scriptedApi({ settings: { - describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })), openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })), - update: record('settings.update', r => ok(r, view)), - replace: record('settings.replace', r => ok(r, view)), - mutate: record('settings.mutate', r => ok(r, view)), - }, - credentials: { - describe: record('credentials.describe', r => ok(r, { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } })), - set: record('credentials.set', r => ok(r, {})), - unset: record('credentials.unset', r => ok(r, {})), }, llm: { providers: record('llm.providers', r => ok(r, { providers: [providerRow] })), @@ -337,23 +308,7 @@ describe('config unary surface', () => { }) const c = client(api) - const described = await c.settings.describe({}) - expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } }) expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } }) - const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } }) - expect(updated.result).toEqual({ ok: true, value: view }) - const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} }) - expect(replaced.result).toEqual({ ok: true, value: view }) - const mutated = await c.settings.mutate({ - ns: 'llm-deepseek', - ops: [{ op: 'unset', path: ['baseURL'] }], - expectedRevision: 0, - }) - expect(mutated.result).toEqual({ ok: true, value: view }) - const creds = await c.credentials.describe({ refs: ['OPENAI_API_KEY'] }) - expect(creds.result).toEqual({ ok: true, value: { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } } }) - expect((await c.credentials.set({ ref: 'OPENAI_API_KEY', value: 'sk-x' })).result).toEqual({ ok: true, value: {} }) - expect((await c.credentials.unset({ ref: 'OPENAI_API_KEY' })).result).toEqual({ ok: true, value: {} }) const providers = await c.llm.providers({}) expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } }) const models = await c.llm.models({}) @@ -375,29 +330,16 @@ describe('config unary surface', () => { expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } }) expect(seen.map(call => call.method)).toEqual([ - 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', - 'credentials.describe', 'credentials.set', 'credentials.unset', + 'settings.openDocument', 'llm.providers', 'llm.models', 'llm.discoverModels', ]) - expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } }) - expect(seen[4]?.payload) - .toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 }) - expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' }) // The draft crosses whole, credential included: the host needs it for this // one interrogation and stores none of it. - expect(seen[10]?.payload).toEqual({ + expect(seen[3]?.payload).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1', api: 'openai-completions', apiKey: 'probe-key', }) }) - - it('rejects an invalid credential reference name at the carrier boundary', async () => { - const api = scriptedApi() - const response = await client(api).credentials.set({ ref: 'not a var', value: 'x' }) - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.code).toBe('bad-request') - }) }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 9bbd19640b..284a973cb4 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -33,31 +33,8 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { }, }, settings: { - async describe(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } } - }, async openDocument(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } - }, - async update(request) { - return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } } - }, - async replace(request) { - return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } } - }, - async mutate(request) { - return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } } - }, - }, - credentials: { - async describe(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { credentials: {} } } } - }, - async set(request) { - return { rpcId: request.rpcId, result: { ok: true, value: {} } } - }, - async unset(request) { - return { rpcId: request.rpcId, result: { ok: true, value: {} } } + return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } }, }, llm: { @@ -102,9 +79,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) it('carries a business error as 200 + error result', async () => { - const response = await client().settings.update({ ns: 'test', patch: {} }) + const response = await client().settings.openDocument({}) expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('settings-rejected') + if (!response.result.ok) expect(response.result.error.code).toBe('internal') }) it('round-trips the agent-preset document opener', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 429e31361f..3f863b7817 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -37,10 +37,6 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') - expect(rpcErrorSchema.parse({ code: 'settings-rejected', message: 'm', details: { ns: 'n' } }).code).toBe('settings-rejected') - expect(rpcErrorSchema.parse({ code: 'settings-conflict', message: 'm', details: { ns: 'n', expected: 1, actual: 2 } }).code).toBe('settings-conflict') - // The credentials producer still emits this code, so the branch has to stay. - expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected') expect(rpcErrorSchema.parse({ code: 'model-discovery-failed', message: 'm', details: { settingsNs: 'n' } }).code).toBe('model-discovery-failed') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) From fcba3bbacb6a3c504019fde85a9815a6e51ffad1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:06:58 +0800 Subject: [PATCH 14/18] test(api): avoid mixed Remote result inference --- .../settings-controller/tests/settings-controller.host.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index ed6e9325fd..ff169f2ffe 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -92,7 +92,7 @@ describe('the settings Remote namespace a configuration page calls', () => { () => ctx.settingsController.replace('ui-test', {}, undefined), () => ctx.settingsController.mutate('ui-test', [], undefined), ]) { - const failure = await Promise.resolve().then(call).catch((error: unknown) => error) + const failure = await Promise.resolve().then(() => call()).catch((error: unknown) => error) expect(failure).toBeInstanceOf(TypertRemoteFailure) expect((failure as TypertRemoteFailure).failure).toEqual({ code: 'internal', From 9fdbec00eecec80584606a4f0bc040c042b0cf6b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:06:59 +0800 Subject: [PATCH 15/18] fix(web): reuse the preview Remote helper --- apps/web/tests/preview-boot.e2e.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index cf7a720ac8..9ffb31a2ae 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -365,21 +365,6 @@ async function bootPreview(origin: string, browser: Browser): Promise { // Settings and credentials both answer over the Remote carrier, so this // half of the sweep posts the generated endpoints directly like the // session read above. - const remote = async (endpoint: string, args: object): Promise => { - const answered = await transport.fetch(`/api/${endpoint}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-request', - rpcId: `preview-${endpoint.replace('/', '-')}`, - method: endpoint, - payload: { args }, - }), - }) - const body = await answered.json() as Result - if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) - return body.result.value - } const settings = await remote<{ namespaces: { ns: string; revision: number }[] }>( 'settings/describe', {}, ) From f19c25123de049b09c4adf051b280458ff5de1ef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:08:53 +0800 Subject: [PATCH 16/18] style(apiproxy): remove stale spacing --- packages/host/apiproxy/src/api-proxy.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7aecfea3ad..30844d9bbb 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -178,8 +178,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return defaults.openPath !== undefined || canOpenNativePath() } - - return { host: { describe(request) { From 08176e6626a2fca9144c13693c0503201a0a66ae Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:12:36 +0800 Subject: [PATCH 17/18] test(api): type heterogeneous provider calls --- .../tests/settings-controller.host.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index ff169f2ffe..894fbddd53 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -86,13 +86,14 @@ describe('the settings Remote namespace a configuration page calls', () => { it('reports the actionable configuration error while no settings provider is mounted', async () => { const ctx = new Context() await ctx.plugin(SettingsController) - for (const call of [ + const calls: Array<() => unknown> = [ () => ctx.settingsController.describe(), () => ctx.settingsController.update('ui-test', {}, undefined), () => ctx.settingsController.replace('ui-test', {}, undefined), () => ctx.settingsController.mutate('ui-test', [], undefined), - ]) { - const failure = await Promise.resolve().then(() => call()).catch((error: unknown) => error) + ] + for (const call of calls) { + const failure = await Promise.resolve().then(call).catch((error: unknown) => error) expect(failure).toBeInstanceOf(TypertRemoteFailure) expect((failure as TypertRemoteFailure).failure).toEqual({ code: 'internal', From f3e16c9bccfc5b9804d996169c4b8e101dd74bfa Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:28:30 +0800 Subject: [PATCH 18/18] docs(api): refresh configuration type records --- docs/subsystems/credentials.i18n.yaml | 4 ++-- docs/subsystems/credentials.md | 4 ++-- docs/subsystems/credentials.zh.md | 4 ++-- packages/api/settings-controller/README.i18n.yaml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 23c65ec57f..b9f925005d 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/credentials.md -credentials.md: 90c10aa65f4b559eb72d4973945272d8d9322649 -credentials.zh.md: 9e3f2b8cd046e8245145f778a6c8fd6663b12f90 +credentials.md: 60cd388fc18fe239b9ee5207d77d3b5561b7a3b3 +credentials.zh.md: 5ca47fac6c36c14eb1ee0aa8a6cb6a8b5e3eb9c4 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 90c10aa65f..60cd388fc1 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -40,11 +40,11 @@ interface ResolvedCredential { * lets the whole read half cross the Remote wire. */ interface CredentialInfo { - /** Whether `CredentialProvider.resolve` would currently return a value. */ + /** Whether resolving the reference would currently return a value. */ configured: boolean /** Source layer currently supplying the value; absent while unconfigured. */ source?: string - /** Whether `CredentialProvider.set` would currently succeed for this reference. */ + /** Whether the active provider can write this reference. */ writable: boolean } ``` diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 9e3f2b8cd0..5ca47fac6c 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -40,11 +40,11 @@ interface ResolvedCredential { * lets the whole read half cross the Remote wire. */ interface CredentialInfo { - /** Whether `CredentialProvider.resolve` would currently return a value. */ + /** Whether resolving the reference would currently return a value. */ configured: boolean /** Source layer currently supplying the value; absent while unconfigured. */ source?: string - /** Whether `CredentialProvider.set` would currently succeed for this reference. */ + /** Whether the active provider can write this reference. */ writable: boolean } ``` diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index 8cab324b55..f393e41d2e 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/settings-controller/README.md -README.md: 71a44ce015f5ecd6c002d29f95df984281851c1a -README.zh.md: 5776013f3d891cee59d7e0cc0f3f92dc02a15530 +README.md: f57bab1cf68ff05d807102a831f4ad9ce65dba76 +README.zh.md: 41062db004b8c98f544f70c42137a71aee997ec8