diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 900e1d01b6..27911294a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 -2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 +2026-07-28-directory-picker-capability-seam.md: 495062f910785e1bb2f421dbb25c01c399d45567 +2026-07-28-directory-picker-capability-seam.zh.md: 62fc87212ab627ea8819dab55e3a769b4a5afc42 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index ad2aa904be..495062f910 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. +- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. @@ -38,7 +38,7 @@ Placement and policy rulings folded into this decision: ## Consequences -- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together; `-native` remains the host-display alternative. +- `cordis.yml` chooses the interaction; `apps/cli` mounts the [`-auto` chooser](../feature/2026-07-29-directory-picker-adaptive-default.md), which resolves the host's situation at boot and mounts `-native` or `-browse` itself, one row still swapping backend and UI together; composing a backend row directly pins the interaction. - The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests. - A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 30e719ad9b..62fc87212a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 +- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 @@ -38,7 +38,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 挂 `-browse`(随附默认——开箱即得可远程的选取),一行同时切换了后端与 UI;`-native` 仍是宿主屏幕方案。 +- `cordis.yml` 决定交互形态;`apps/cli` 挂 [`-auto` 选择器](../feature/2026-07-29-directory-picker-adaptive-default.md),它在启动时判定宿主处境并自行挂载 `-native` 或 `-browse`,一行仍同时切换后端与 UI;直接组合某个后端行即固定交互。 - 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。 - 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml new file mode 100644 index 0000000000..736a372f27 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.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/architecture/2026-07-28-user-settings-seam.md +2026-07-28-user-settings-seam.md: bf93f95168b1b6d0dec5a9fc2c9aac5531f0564a +2026-07-28-user-settings-seam.zh.md: 8cd4dfcbb2facdd590b2c24d453ad79e9badda4d diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md new file mode 100644 index 0000000000..bf93f95168 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md @@ -0,0 +1,35 @@ +# Agent Note: user-settings seam (`ctx.settings`) and the file provider + +Status: implemented + +English | [中文](2026-07-28-user-settings-seam.zh.md) + +> Scope: the `packages/settings/` capability family — the abstract seam, the file-backed provider, and the composition boundary between user settings and `cordis.yml`. The [web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) recorded "the profile write path" as a deferral; this seam is that write path's owner. Consumer migrations (theme, locale, default model route) and the web `settings.*` RPC surface are follow-ups, not part of this note's shipped scope. + +## Problem + +User-editable configuration had no owner: `dsh web` read a cwd-anchored profile json through a static whitelist with no write path, the TUI read `$DSH_HOME/config.yaml` raw loader patches, and both froze at boot. A personal-settings page (web GUI) needs one cross-surface user layer with schema validation, a write path, and hot propagation — and peer products (Codex, Claude Code, Kimi, OpenCode, Pi) all converged on separating user preferences from extension composition. Loader-reactive config updates cannot carry this: `fiber.update` swaps entry config in place, so a plugin that read config at construction observes nothing and no callback tells it otherwise. + +## Decision + +**Two planes with a litmus test.** `cordis.yml` (+ Include patches) stays the composition plane: which plugins exist, wiring, deployment config, owned by the orchestrator and upgraded with the product. A settings namespace carries only the user-editable subset; the test is "should the personal config page edit it?" Values live in both planes without ambiguity because layering is the contract: schema defaults, then the registrant's composition `base` (its entry-config subset), then the user document section. + +**Three-package seam mirroring `session-persistence/`.** `dsh-settings` owns the abstract `Settings` service: namespace registry, layered resolution, schema validation, per-namespace deep-equal change detection, and the `settings/updated` commit event. Providers implement only `writable`/`load()`/`persist(ns, section)` and push externally observed documents through the protected `publish(doc)` — so hot-update semantics are identical across providers, and a network configuration-center backend (nacos-style, possibly read-only) is a sibling package away. `dsh-settings-local` is the file provider: YAML/JSON under `resolveSpec` (explicit defaulting to `/settings.yaml`), chokidar watch, read-modify-write persists under a cross-process writer lock with atomic `0600` tmp+rename commits, leaf-level diff patching of the written namespace (comments survive untouched nodes), and content-equality self-write suppression ([write-path integrity note](2026-07-30-settings-write-path-integrity.md)). + +**Registrations are caller-fiber effects.** `register()` runs through the service proxy, so `this.ctx` is the registrant's context and the registration rides `ctx.effect`: disposing the registrant removes the namespace and its watchers (proven by the HMR disposal test), while the user's section keeps living in storage for the next owner. + +**Fail loud at rest, last-good in motion.** Boot-time and registration-time validation throw (invalid stored section fails the registering plugin; an existing-but-unparsable document fails provider load). Once live, a bad external edit warns and keeps the last good state per namespace — a hot reload must never take the process down. This asymmetry mirrors `Include.refresh()` and Kimi's safe runtime reload. + +**Consumers stay optional-by-construction.** A consumer registers inside `ctx.inject(['settings'], …)`; without a mounted provider it keeps resolving entry config alone, so every existing composition, demo, and snapshot works unchanged and migration is per-plugin. + +## Alternatives considered + +- **Include write-back as the user layer** (per-plugin config pages writing loader entry files, cordis-webui style): write-back would target per-composition files, binding user preferences to one `cordis.yml`; a per-user layer must survive template upgrades and serve TUI and web from one document. +- **Loader-reactive `fiber.update` as the propagation channel**: constructor-time reads observe nothing; the seam's explicit `watch()` makes hot-update a consumer contract instead of framework magic. +- **A domain-aware settings service** (getters per product area): the coupling objection from design review stands; the service stores, validates, and publishes — domain meaning stays with the registrant that owns the schema. +- **Multi-layer precedence now** (system/managed/project tiers à la Codex/Claude Code): deferred until a real second layer exists; the resolve step is the single place layering would extend. +- **A cross-process lockfile now** (Pi's proper-lockfile): initially deferred as "atomic replace plus watcher convergence until real contention shows up" — review showed convergence loses unobserved sibling namespaces, so the deferral is superseded by the [write-path integrity note](2026-07-30-settings-write-path-integrity.md)'s hand-rolled writer lock. + +## Consequences + +Deferred, in dependency order: the web `settings.raw`/`settings.describe`/`settings.update` RPC surface (which must redact `role('secret')` fields before exposure); first consumer migrations (`ui-theme`, locale, api-gateway default route) retiring `PROFILE_MAPPINGS` and the profile json; `${env:VAR}` value indirection for secrets; provider-side layering. The keyless snapshot obligation lands with the first model- or product-user-visible consumer, not with this infrastructure step. diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md new file mode 100644 index 0000000000..8cd4dfcbb2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md @@ -0,0 +1,35 @@ +# Agent Note:用户设置 seam(`ctx.settings`)与文件 provider + +Status: implemented + +[English](2026-07-28-user-settings-seam.md) | 中文 + +> 范围:`packages/settings/` 能力族——抽象 seam、文件 provider,以及用户设置与 `cordis.yml` 的组合边界。[web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) 曾把"profile 写路径"记为延后项;本 seam 就是该写路径的归属。消费者迁移(主题、语言、默认模型路由)与 web `settings.*` RPC 面是后续工作,不在本 note 已交付范围内。 + +## 问题 + +用户可编辑配置没有归属:`dsh web` 经静态白名单读 cwd 锚定的 profile json 且无写路径,TUI 读 `$DSH_HOME/config.yaml` 裸 loader patch,两者都在启动时冻结。个人设置页(web GUI)需要一个跨 surface 的用户层,带 schema 校验、写路径与热传导——同类产品(Codex、Claude Code、Kimi、OpenCode、Pi)也全部收敛于"用户偏好与扩展组合分离"。Loader 的 reactive 配置更新承载不了这件事:`fiber.update` 原地替换 entry config,构造期读过配置的插件毫无感知,也没有任何回调通知它。 + +## 决策 + +**两个面,一条判定。**`cordis.yml`(+ Include patches)仍是组合面:有哪些插件、接线、部署配置,归 orchestrator 所有并随产品升级。settings namespace 只承载用户可编辑子集;判定是"个人配置页应该能改它吗?"值可同时存在于两个面而不歧义,因为分层就是契约:schema 默认值,然后注册方的组合 `base`(其 entry 配置子集),最后用户文档分节。 + +**镜像 `session-persistence/` 的三包 seam。**`dsh-settings` 拥有抽象 `Settings` 服务:namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测,以及 `settings/updated` 提交事件。provider 只实现 `writable`/`load()`/`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档——因此热更新语义对所有 provider 一致,网络配置中心后端(nacos 类,可能只读)只是一个平级包的距离。`dsh-settings-local` 是文件 provider:`resolveSpec` 显式默认到 `/settings.yaml` 的 YAML/JSON、chokidar 监听、跨进程写锁下以 `0600` tmp+rename 原子提交的读-改-写 persist、对被写 namespace 的叶子级 diff 修补(未触碰节点的注释得以保留)、按内容相等抑制自写([write-path integrity note](2026-07-30-settings-write-path-integrity.md))。 + +**注册是调用方 fiber 上的 effect。**`register()` 经服务代理调用,`this.ctx` 即注册方 context,注册挂在 `ctx.effect` 上:dispose 注册方即移除 namespace 及其观察者(HMR disposal 测试证明),而用户的分节继续留在存储中等待下一任 owner。 + +**静止时响亮报错,运行中保留最后可用值。**启动期与注册期校验直接抛错(非法存量分节使注册插件加载失败;存在但不可解析的文档使 provider 加载失败)。运行中坏的外部编辑只告警并按 namespace 保留最后可用状态——热重载绝不拖垮进程。该不对称镜像 `Include.refresh()` 与 Kimi 的安全运行时重载。 + +**消费者天然可选。**消费者在 `ctx.inject(['settings'], …)` 内注册;不挂 provider 时仍只按 entry 配置解析,因此所有既有组合、demo、snapshot 原样工作,迁移按插件渐进。 + +## Alternatives considered + +- **以 Include 写回为用户层**(cordis-webui 式的按插件配置页写 loader entry 文件):写回目标是按组合的文件,会把用户偏好绑死在某个 `cordis.yml` 上;用户层必须在模板升级中存活,并以同一文档服务 TUI 与 web。 +- **以 Loader reactive `fiber.update` 为传导通道**:构造期读取毫无感知;seam 的显式 `watch()` 把热更新变成消费者契约而非框架魔法。 +- **领域化的 settings 服务**(按产品域的 getter):设计评审中的耦合反对成立;服务只做存储、校验、发布——领域含义留给拥有 schema 的注册方。 +- **现在就做多层优先级**(Codex/Claude Code 式 system/managed/project 层级):延后到真实第二层出现;resolve 步骤是分层未来唯一的扩展点。 +- **现在就上跨进程锁**(Pi 的 proper-lockfile):最初以"原子替换加 watcher 收敛,真实冲突出现再说"为由延后——评审发现收敛会丢失未观察到的同级 namespace,因此该延后已被 [write-path integrity note](2026-07-30-settings-write-path-integrity.md) 的手写写锁取代。 + +## 后果 + +按依赖顺序延后:web `settings.raw`/`settings.describe`/`settings.update` RPC 面(暴露前必须对 `role('secret')` 字段脱敏);首批消费者迁移(`ui-theme`、语言、api-gateway 默认路由)并退役 `PROFILE_MAPPINGS` 与 profile json;面向密钥的 `${env:VAR}` 值间接引用;provider 侧分层。keyless snapshot 义务随第一个模型或产品用户可见的消费者落地,而非本基础设施步骤。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml new file mode 100644 index 0000000000..fa54d657a9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.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/architecture/2026-07-30-settings-write-path-integrity.md +2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc +2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md new file mode 100644 index 0000000000..07bd095162 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md @@ -0,0 +1,35 @@ +# Agent Note: settings write-path integrity and observer lifecycle + +Status: implemented + +English | [中文](2026-07-30-settings-write-path-integrity.zh.md) + +> Scope: the third review round over `packages/settings/` — write-path data integrity in `dsh-settings-local` (operation chain, read-modify-write, cross-process writer lock, diff-shaped YAML edits) and observer lifecycle in `dsh-settings` (watch disposal, async listener containment, the JSON-shape write boundary). This note reverses one deferral recorded in the [user-settings seam note](2026-07-28-user-settings-seam.md): the cross-process lockfile now ships. + +## Problem + +Review found the provider's write path could destroy state it never observed, and the seam's observer lifecycle leaked past disposal. Concretely: watcher reloads and document writes ran on two independent promise chains while every write rendered the whole next document from the cached text, so an external edit still inside the debounce window was overwritten — and the follow-up reload no-oped because the post-rename content matched the cache, erasing the edit without a trace. The initial `load()` raced the watcher's own setup, leaving a startup window whose changes never fire an event. Two processes sharing a harness home rendered from independent caches, last writer winning whole namespaces. On the seam side, a `watch()` disposer only removed the observer from its set — an invocation already chained onto the watcher tail still ran after disposal, and nothing drained started invocations at service dispose; the `settings/updated` manual fan-out caught only synchronous throws, so an async listener's rejection escaped as an unhandled rejection; and `structuredClone` admitted Dates, Maps, BigInts, and cycles that YAML/JSON storage silently distorts on the reload round-trip (a Date lands as a timestamp string, a BigInt as a plain number). YAML writes replaced the whole namespace node, deleting every comment inside the section a comment-preserving provider had promised to keep. + +## Decision + +**One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active. + +**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste. + +**Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is. + +**The write boundary admits JSON data only.** The call-time snapshot is a single `cloneJsonShaped` walk that detaches the patch and rejects any non-JSON value — Date, Map, BigInt, non-finite number, function, symbol, class instance, `undefined` array entry, circular reference — with its `$`-rooted path before anything persists. Object entries that are explicitly `undefined` still skip (the sparse-patch contract), now enforced at the boundary instead of inside `mergeLayers`. + +**YAML edits are leaf-level diffs.** `renderYaml` diffs the stored section against the next one and applies only `setIn` for changed values and `deleteIn` for removed keys, recursing through maps. Comments, anchors, and formatting survive on every untouched node and on the key node of every changed pair; arrays and other non-map values replace wholesale when unequal (`deepEqualJson` is the shared predicate), taking comments inside them along. + +## Alternatives considered + +- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer. +- **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free. +- **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded. +- **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime. +- **Keeping `structuredClone` and validating in the provider** — the seam is the durable boundary's owner (every provider stores JSON-shaped documents), and rejecting at call time gives the caller the offending path; a provider-side check would reject after merge, blaming the merged section instead of the caller's value. + +## Consequences + +`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md new file mode 100644 index 0000000000..5d02177073 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -0,0 +1,41 @@ +# Agent Note: settings 写路径完整性与观察者生命周期 + +Status: implemented + +[English](2026-07-30-settings-write-path-integrity.md) | 中文 + +> 范围:对 `packages/settings/` 的第三轮评审——`dsh-settings-local` 的写路径数据完整性(操作链、读-改-写、跨进程写锁、diff 形态的 YAML 编辑)与 `dsh-settings` 的观察者生命周期(watch 的 dispose(资源释放)、异步监听器收容、JSON 形态写入边界)。本 note 推翻了[用户设置 seam note](2026-07-28-user-settings-seam.md)所记录的一项延后决定:跨进程锁文件现已交付。 + +## 问题 + +评审发现,提供方的写路径可能销毁它从未观察到的状态,而 seam 的观察者生命周期会泄漏到 dispose 之后。具体而言:watcher 重载与文档写入跑在两条相互独立的 promise 链上,而每次写入都从缓存文本渲染出完整的下一份文档,于是仍处于防抖窗口内的外部编辑会被覆盖——随后的重载又因 rename 后的内容与缓存一致而成为空操作,这次编辑就被无痕抹去。初始 `load()` 与 watcher 自身的建立过程存在竞态,留下一个启动窗口:落在这个窗口内的变更永远不会触发事件。共享同一 harness home 的两个进程各自从独立的缓存渲染,后写者以整个 namespace 为单位胜出。 + +在 seam 一侧,`watch()` 的释放器只把观察者从集合中移除——已经接到 watcher 链尾的调用在 dispose 之后照常运行,服务 dispose 时也没有任何环节排空已启动的调用;`settings/updated` 的手动扇出只捕获同步抛错,异步监听器的 rejection 会以 unhandled rejection 的形式逃逸;`structuredClone` 则放行 Date、Map、BigInt 与循环引用,而 YAML/JSON 存储会在重载往返中悄悄扭曲这些值(Date 会变成时间戳字符串,BigInt 会变成普通数字)。 + +YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删掉——而这个保注释的提供方承诺过要保住它们。 + +## 决策 + +**单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。 + +**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行:指数退避、2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好。 + +**观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。 + +**写入边界只放行 JSON 数据。**调用时刻的快照就是一次 `cloneJsonShaped` 遍历:它把 patch 从调用方分离出来,并在任何内容持久化之前拒绝一切非 JSON 值——Date、Map、BigInt、非有限数值、函数、symbol、类实例、值为 `undefined` 的数组元素、循环引用——拒绝时附带该值以 `$` 为根的路径。显式为 `undefined` 的对象条目仍会跳过(稀疏 patch 契约),这一契约如今在边界处强制执行,而不再放在 `mergeLayers` 内部。 + +**YAML 编辑是叶子级 diff。**`renderYaml` 对比已存储分节与下一份分节,只对变化的值应用 `setIn`、对移除的键应用 `deleteIn`,并沿 map 递归。注释、锚点与格式在每个未触碰节点上、以及每个被改键值对的键节点上全部保留;数组等非 map 值在不相等时整体替换(`deepEqualJson` 是共享的判定谓词),其内部注释随之一并被带走。 + +## 曾考虑的替代方案 + +- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁约 40 行并带确定性测试(含注入的 `EEXIST`/`stat` 竞态)。该政策偏向能删除自有代码的依赖;这个依赖只会把 40 行带解释的代码换成一个不透明的等价物。 +- **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。 +- **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。 +- **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。 +- **保留 `structuredClone`、在提供方里做校验**——seam 才是持久化边界的所有者(每个提供方存储的都是 JSON 形态文档),而且在调用时刻拒绝能把违规值的路径给到调用方;提供方侧的检查要到合并之后才拒绝,归咎的是合并后的分节,而不是调用方传入的值。 + +## 后果 + +`update()` 有了成文的失败模式(锁截止时间到期、磁盘文档非法),rejection 消息携带以 `$` 为根的路径。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。 + +[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.i18n.yaml new file mode 100644 index 0000000000..6a898fa0a4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md +2026-07-27-glob-sampling.md: 67912cf290b127a96819d57f30387197af68323d +2026-07-27-glob-sampling.zh.md: e339a5f87b483849df60feb726b061fe85074300 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md new file mode 100644 index 0000000000..67912cf290 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md @@ -0,0 +1,47 @@ +# Agent Note: Sample over-cap glob results across the tree + +Status: implemented + +English | [中文](2026-07-27-glob-sampling.zh.md) + +## Problem + +Asked what a workspace contained, an agent described one subfolder as if it were the whole project. The workspace held 22 top-level entries and 11,485 files. `glob {"pattern":"*"}` matched 10,030 paths, but all 100 inline paths sat under one recently unpacked subtree, so the model never saw the other 21 entries. + +Three individually valid behaviors composed into the false impression. A glob without `/` matches basenames at any depth, so `*` means every file in the tree rather than the shell's current-directory expansion. Ripgrep's `--sort=modified` is ascending, so an archive's restored old timestamps put that subtree first. The inline page then took the head of that order without saying that it represented only one concentrated slice. + +## Decision + +A result that fits within `globMaxResults` remains complete and byte-for-byte modification-time ordered. The required `sampleOverCapGlobResults` config has no fallback: `false` retains the modification-time head for an over-cap result, while `true` samples round-robin across the complete result's top-level entries. In sampling mode, every entry receives one slot before any receives a second, exhausted groups drop out, relative order remains stable within each group, and grouping is relative to the actual search root, including an explicit `path`. + +In sampling mode, the footer states that the page is a cross-entry sample rather than the modification-time head and reports how many top-level entries it reaches when that fact adds information. When more top-level entries exist than inline slots, it tells the model to narrow `path`. Head mode keeps the ordinary capped-result footer. When spill succeeds, both modes preserve the complete sorted list in the artifact. + +The prompt and schema state the configured over-cap ordering, that a pattern without `/` matches at any depth, and that glob returns files, never directory entries. The shipped CLI composition explicitly selects head mode; deployments that want representative capped pages select sampling mode. Directory orientation remains ordinary shell work in deployments that expose the model-facing bash tool: use `ls` for one directory, and glob for a named file-path pattern across the tree. `ctx.fs.listDir` remains an internal provider primitive used by skill discovery; this decision adds no model-facing `list` tool. + +## Alternatives considered + +**Keep the modification-time head as the only behavior.** Rejected after measuring the failure shape. Some deployments need the stable ordering, but a deployment that values workspace orientation can explicitly select representative data instead of asking the model to distrust the only paths it received. + +**Give the sampling choice a default.** Rejected. No product-wide evidence establishes either ordering as the implicit contract, so every composition selects one and misconfiguration fails at load. + +**Sample every result.** Rejected. A complete result loses nothing to truncation, so modification-time order remains useful for age-oriented questions. Sampling begins only when the head stops describing the whole. + +**Switch to newest-first order.** Rejected. It merely changes which concentrated subtree can dominate and removes the existing oldest-first contract without making a capped page representative. + +**Sample only past a skew threshold.** Rejected. No current evidence supports a deployment-wide threshold, and the model could not know which ordering contract applied. The existing cap is the explainable transition. + +**Balance recursively below the top level.** Deferred. First-segment balance fixes the observed failure; deeper balancing needs an unsupported depth-versus-breadth policy. + +**Add a model-facing `list` tool.** Rejected after implementation review. The default coding composition already exposes general bash and the model understands `ls`; a duplicate tool would add permanent schema/prompt tokens plus ordering, pagination, symlink, escaping, UI, and snapshot contracts without a distinct security or policy benefit. Thin deployments without a model-facing bash tool do not gain directory orientation from this change. + +**Reject `*` or silently anchor separator-free patterns.** Rejected. The same basename-at-any-depth behavior makes `*.ts` useful across a tree. Documenting the rule preserves working ripgrep semantics. + +## Consequences + +A sampling-mode over-cap page no longer answers age-order questions from its inline paths; its footer says so, and the spill artifact retains the complete sorted view. Sampling balances only the first segment beneath the search root, so a deeper hot subtree can still dominate within one top-level entry. Head mode retains the concentration risk as an explicit deployment trade-off. + +The tool surface does not grow. Every composition must set `sampleOverCapGlobResults`; changing it alters glob's prompt, schema description, and over-cap Native rendering. The canonical output keeps `root` so sampling mode can recover its grouping basis, while fitting results remain unchanged. + +## Testing + +Package tests pin the required config, both over-cap modes, their prompt and schema descriptions, concentrated and flat results, explicit roots, more groups than the JavaScript argument limit, exhausted groups, fewer slots than groups, and paths outside the workdir. The `fs-glob-sampling` ACP scenario explicitly enables sampling, boots a minimal real Loader/app/local-bash composition, and executes the real search plugin against a deterministic `rg` process fixture; its result spans four top-level entries instead of returning one subtree's head. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md new file mode 100644 index 0000000000..e339a5f87b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 跨目录树采样超出上限的 glob 结果 + +Status: implemented + +[English](2026-07-27-glob-sampling.md) | 中文 + +## 问题 + +用户询问工作区包含什么内容时,一个 agent(智能体)把某个子文件夹描述成了整个项目。该工作区有 22 个顶层条目和 11,485 个文件。`glob {"pattern":"*"}` 匹配到 10,030 条路径,但内联显示的 100 条路径全部位于一棵近期解压的子树中,因此模型完全没有看到其余 21 个条目。 + +三个单独看都合理的行为叠加后造成了错误印象。不含 `/` 的 glob 会匹配任意深度的文件名,因此 `*` 表示目录树中的每个文件,而不是 shell 对当前目录执行的展开。Ripgrep 的 `--sort=modified` 按升序排列,因此归档包还原出的旧时间戳会让该子树排在最前。随后,内联页面直接截取这一顺序的前部,却没有说明它只代表集中于一处的切片。 + +## 决策 + +未超过 `globMaxResults` 的结果仍保持完整,且按修改时间排序的内容逐字节不变。必填的 `sampleOverCapGlobResults` 配置没有回退值:`false` 会为超过上限的结果保留按修改时间排序的前部,`true` 则会在完整结果的顶层条目之间按轮转方式采样。采样模式下,每个条目都先获得一个位置,之后才有条目获得第二个位置;已经用尽的分组会退出轮转;各组内部的相对顺序保持稳定;分组以实际搜索根为基准,显式指定 `path` 时也如此。 + +采样模式下,footer 会说明当前页面是跨条目的样本,而不是按修改时间排序的前部;当触达的顶层条目数能提供额外信息时,还会报告该数量。若顶层条目数量超过内联位置数,footer 会要求模型缩小 `path`。保留前部的模式沿用达到上限时的普通 footer。spill 成功时,两种模式都会在该产物中保留完整排序列表。 + +提示词与 schema 会说明配置所指定的超限结果排序方式、不含 `/` 的模式会匹配任意深度,以及 glob 只返回文件而绝不返回目录条目。随产品交付的 CLI(命令行界面)组合显式选择保留前部的模式;希望达到上限的页面具有代表性的部署则选择采样模式。在向模型暴露 bash 工具的部署中,目录定位仍由普通 shell 操作完成:查看一个目录使用 `ls`,跨目录树按指定文件路径模式查找则使用 glob。skill(技能)发现流程仍将 `ctx.fs.listDir` 作为内部提供方原语使用;本决策不会新增面向模型的 `list` 工具。 + +## 考虑过的替代方案 + +**只保留按修改时间排序的前部。** 测量实际故障形态后否决。某些部署需要这种稳定排序;但重视工作区定位的部署可以显式选择具有代表性的数据,而不必要求模型怀疑自己拿到的唯一一批路径。 + +**为采样选项提供默认值。** 否决。没有全产品范围的证据支持把任一排序作为隐式契约,因此每个组合都必须选择一种,配置错误则在加载时失败。 + +**对所有结果采样。** 否决。完整结果没有因截断损失任何信息,因此按修改时间排序仍有助于回答关注新旧时间的问题。只有当截取前部已经无法描述整体时,才开始采样。 + +**改为最新优先排序。** 否决。这只会改变哪一棵结果集中的子树可能占据主导;既取消了现有的最旧优先契约,也没有让受限页面更具代表性。 + +**仅在偏斜超过阈值时采样。** 否决。目前没有证据支持适用于所有部署的统一阈值,模型也无法判断当前采用的是哪一种排序契约。现有上限是可以清楚解释的切换点。 + +**在顶层以下递归平衡。** 暂缓。按第一路径段做平衡已经修复观测到的故障;更深层的平衡需要一套尚无依据的深度与广度取舍策略。 + +**新增面向模型的 `list` 工具。** 实现评审后否决。默认编程组合已经提供通用 bash,模型也理解 `ls`;重复工具会永久增加 schema 与提示词所占的 token,并引入排序、分页、符号链接、转义、UI 与快照契约,却没有独立的安全或策略收益。不向模型提供 bash 工具的精简部署也不会因本次改动获得目录定位能力。 + +**拒绝 `*`,或在不含分隔符的模式前静默加上根目录锚点。** 否决。同样的「在任意深度匹配文件名」行为使 `*.ts` 可以有效地跨目录树搜索。记录这条规则能够保留正常工作的 Ripgrep 语义。 + +## 影响 + +采样模式下,超过上限的 glob 页面无法再根据内联路径回答按时间判断新旧的问题;footer 会明确说明这一点,spill 产物仍保留完整的排序视图。采样只平衡搜索根下的第一路径段,因此某个顶层条目内部较深处、结果密集的子树仍可能占据主导。保留前部的模式则把集中风险作为显式部署取舍保留下来。 + +工具接口不会扩大。每个组合都必须设置 `sampleOverCapGlobResults`;更改该值会改变 glob 的提示词、schema 描述以及超过上限时的 Native 渲染。规范输出保留 `root`,以便采样模式恢复其分组基准;未超过上限的结果保持不变。 + +## 测试 + +包测试锁定了必填配置、两种超过上限模式及其提示词和 schema 描述、结果集中与扁平两种情况、显式根目录、分组数超过 JavaScript 参数个数上限、分组耗尽、位置数少于分组数,以及工作目录以外的路径。`fs-glob-sampling` ACP(Agent Client Protocol)场景会显式启用采样,启动最小化的真实 Loader/app/local-bash 组合,并让真实搜索插件对接确定性的 `rg` 进程 fixture(测试前置数据);其结果覆盖 4 个顶层条目,而不是只返回某棵子树的前部。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml index a205f48f54..45e957b824 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md -2026-07-28-themed-scrollbars-and-reserved-gutter.md: 38228c868bb00210118e8110feb722fb81d0d56c -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 9fafe1faa9303b5e2e23a1b3064904f71494026d +2026-07-28-themed-scrollbars-and-reserved-gutter.md: b45f70b126d083916c756afb88a8b646a4e9bb85 +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 8afa36429ce7e6e061b014d63dcb20e5a642a84c diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md index 38228c868b..b45f70b126 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md @@ -20,13 +20,13 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading. -Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. Eight surfaces rebind today: the command popup, the slash menu, the model-select panel, the settings panel, the shared `ui-primitives` menu card, the composer input card, the question composer card, and the todo panel. Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls. +Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. -The last four were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints an elevated surface somewhere must rebind. +Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were missed in the first implementation and found in review, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection. The elevated set is resolved from the palette's own dark elevation ladder — the surface tokens whose dark value lands on `bg-layer-2` or `bg-layer-3`, which is the step the l1/l2 split encodes. Deriving it instead from the sheets that already rebind was the first attempt and is unsound: such a set can only confirm what someone already remembered, and a surface nobody has rebound yet — exactly the case the check exists for — defines itself as unelevated. `--dsw-specific-tip` proved it, resolving to the menu surface's rung while the todo panel scrolled on it unrebound and the derived check stayed green. -Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules, and CSS text does not express which contains which. +Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules. That approximation cannot detect a scrolling component embedded in an elevated card painted by another package's stylesheet, as `DirectoryBrowser` inside `Modal` demonstrated; cross-sheet composition remains a review and assembled-UI responsibility. The track and the corner stay transparent, so the thumb reads against whatever surface scrolls under it; only the thumb and its hover state carry a token color. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md index 9fafe1faa9..8afa36429c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md @@ -20,13 +20,13 @@ Status: implemented 两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。 -两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。目前有八处抬升表面做了重新绑定:命令浮层、斜杠菜单、模型选择面板、设置面板、`ui-primitives` 共用菜单卡片、输入条卡片、提问组件卡片与待办面板。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 -后四处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制抬升表面,就必须重新绑定。 +`Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面在最初的实现里被漏掉、由评审发现,因此逐样式表的重新绑定契约由机械检查而非人工审阅把关。 抬升表面集合是从调色板自身的暗色抬升阶梯解析出来的——暗色取值落在 `bg-layer-2` 或 `bg-layer-3` 上的那些表面 token,而这一档正是 l1/l2 之分所编码的层级差。最初的做法是从已经做了重新绑定的样式表反向推导,那是不成立的:这样得到的集合只能确认别人已经记得的部分,而尚无人重新绑定的表面——恰恰就是这项检查存在的理由——会把自己定义成「非抬升」。`--dsw-specific-tip` 证明了这一点:它解析到与菜单表面相同的那一档,待办面板在它上面滚动却没有重新绑定,而推导式的检查依然是绿的。 -判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则,而 CSS 文本无法表达谁包含谁。 +判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则。这种近似检查无法检测嵌在由另一个包的样式表绘制的抬升卡片中的滚动组件,`Modal` 内的 `DirectoryBrowser` 就证明了这一点;跨样式表的组合仍需在评审和组装后 UI 层面把关。 轨道与两条滚动条相交的角落保持透明,因此滑块是以其下滚动的任何表面为背景被看到;只有滑块及其 hover 状态带 token 颜色。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml new file mode 100644 index 0000000000..edb6ac6e4a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +2026-07-29-human-transcript-append-origin.md: a296b93d538d9c28bd61ee8fd0530863b4bfd878 +2026-07-29-human-transcript-append-origin.zh.md: 96e0cd1038fe8904dfd4c1eceaae9b25339c5dca diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md new file mode 100644 index 0000000000..a296b93d53 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -0,0 +1,53 @@ +# Agent Note: The human transcript projects append-origin events + +Status: implemented + +English | [中文](2026-07-29-human-transcript-append-origin.zh.md) + +## Problem + +The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message`, `assistant/message`, and `steering/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only provenance and the replacement that cites it. + +Nothing was lost from the log. `Session.events` still held every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection. + +## Decision + +Model and human projections are separate, and the event's own marker decides which one an event belongs to. `dsh-session` exports the marker split `isAppendSurfaceEvent(event)` and `isReplacementSurfaceEvent(event)` over the two `SurfaceOp` variants, from the browser-safe `surface` module. Append-origin events are the durable source for a transcript; replacement copies stay model-only. Everything that must send exactly what the model sees — `deriveMessages`, token accounting, the compaction backends, tool pairing, injected-context liveness, cross-session reference projection — keeps reading `session.surface`. + +The terminal replays the transcript from append-origin surface events and keeps a shadowed step's tool cards paired through `transcriptToolCallIds`, which reads the append-origin `assistant/message` rather than surface membership. A landed compaction contributes one dim `… earlier context was compacted …` row at its own log position: the marker reports where the model stopped seeing that history instead of erasing it. The framed checkpoint payload never renders, and both paths classify a surface event by the same marker, so a compaction that arrives live and the same log replayed after resume produce the same transcript. Only replay re-derives `tool/call` pairing: a call event carries no marker of its own and inherits membership from the `assistant/message` that advertised it, which the live listener has necessarily just rendered. + +A checkpoint is recognized through the compaction seam's own contract — `isCompactCheckpointSource`, the backend-independent marker `CompactService` requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. `dsh-session-reference` already consumes that predicate to project another session's log; this is the same question asked by a different reader. Other replacements are silent: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. + +`session.history` counts only append-origin messages toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compact/summary` provenance stays on the page of the replacement that cites it. + +No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. + +## Deferred + +The browser client still builds its conversation from the model surface through `FoldAdapter`, so compaction still collapses web history to a single context row. The same predicate is the fix there, together with an append-order transcript projection and a marker component; that work is a separate change against `packages/client/runtime` and `packages/client/ui-conversation`. + +That work must handle a page whose checkpoint cites a `surfaceOp.start` outside the window: pagination no longer spends quota on the checkpoint, so it never cuts on the checkpoint's provenance group, and `FoldAdapter` pads absent events with a non-surface sentinel — so `SurfaceManager` rejects the range and `nodes()` falls back to `degradedSeqs()` with a logged error. The hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page. `degradedSeqs()` — every surface-eligible event in append order — is already close to the transcript projection A2 needs, which is the shape to build deliberately rather than reach as a degradation. Rendering compaction *progress* — a terminal indicator while a compaction runs — needs the bracket-first ordering that the queued manual `/compact` work introduces, and is likewise out of scope here. The marker also carries no scale: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. That belongs with progress, where the reader meets the other half of the same information. Whoever takes it should fold the terminal's two replacement branches — replay and the live listener, textually identical and 600 lines apart — into one `renderReplacement(event)` first, so the marker's content has a single home. + +## Alternatives considered + +**Recognize a checkpoint by shape (a replacement `user/message`).** Rejected: it reads a coincidence of today's producers instead of a declared contract, and any future producer that replaces a range with a user message would silently inherit the compaction marker. The seam already publishes `COMPACT_CHECKPOINT_SOURCE` precisely so consumers can recognize a checkpoint independently of the backend. + +**Keep rendering the checkpoint as an injected-context card.** Rejected: the framed checkpoint is an instruction envelope written for the model, not human conversation content. Showing it while hiding the history it replaced inverts what the reader needs. + +**Persist a second display transcript.** Rejected: the append-only log already contains the authoritative source material, so a parallel record buys nothing and adds migration and consistency work. + +**Derive the marker from the `compact/*` bracket instead of the checkpoint.** Rejected for the transcript: the bracket is a pair of time-point markers around an operation, while the transcript needs the position where the surface actually changed. The bracket is the right source for progress and duration, which this change does not render. + +**Classify events by re-folding the log, as `session-query` does for search (`current` / `shadowed` / `log-only`).** Rejected: a fold answers a whole-log question, while a projection asks a per-event one that the event's own marker already answers in constant time. + +## Consequences + +Compaction no longer erases terminal history; a session compacted several times shows one marker per landed compaction, in log order. Pagination pages can carry more raw events than before, because quota is spent only on messages a human or model actually produced. + +`rebuildTranscript` now materializes a component per append-origin event in the whole log, and it runs on mount, on a terminal color-scheme change, and on every reasoning toggle. Compaction used to bound that work for exactly the long sessions compaction serves, so the cost now grows with session length instead of with the surface. That is the trade the fix exists to make — preserved history is the point — but a windowing or reuse strategy belongs to whoever first measures a slow rebuild, not to a later profiler wondering why the work grew. + +`dsh-tui` gains a dependency on the `dsh-compact` seam for one pure predicate, mirroring `dsh-session-reference`'s existing use. The terminal still needs no compaction backend at runtime. + +Two behaviors changed with their tests. The surface-replacement terminal test previously pinned erasure ("hides shadowed tool calls") and now pins preservation plus exactly one marker, including a pruned result copy, a regenerated assistant message, and a foreign plugin's replacement all rendering nothing. The compaction snapshot scenario wrote a `workspace-context` source while claiming to pin compaction; it now writes a real checkpoint source, and its three fixtures are re-recorded to show the preserved prompt, the full tool card, and the marker. + +The live/replay equivalence above is fixture-pinned, not only asserted here: `surface-replayed-compaction` mounts with the replacement already stored and records byte-identical to the live path's `surface-after-compaction-wide`. Changing either path breaks that equality, which is the point — the resume projection is what regressed for users, and the two fixtures must move together. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md new file mode 100644 index 0000000000..96e0cd1038 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 人类可读记录投影追加来源的事件 + +Status: implemented + +[English](2026-07-29-human-transcript-append-origin.md) | 中文 + +## Problem + +终端与宿主历史网关都把模型可见的 surface 当作人类可读记录(transcript)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message`、`assistant/message` 和 `steering/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志溯源信息与引用它的替换之间。 + +日志本身没有丢失任何内容。`Session.events` 仍保存着每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。 + +## Decision + +模型投影与人类投影是分开的,而事件属于哪一种由事件自身的标记决定。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源的事件是记录的持久来源,替换副本仅供模型使用。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。 + +终端从追加来源的 surface 事件回放记录,并通过 `transcriptToolCallIds` 让被遮蔽步骤的工具卡片保持配对:该函数读取追加来源的 `assistant/message`,而不是 surface 成员关系。已落地的压缩会在其自身日志位置贡献一行暗色 `… earlier context was compacted …`:这行标记报告模型从何处起不再看到那段历史,而不是把它抹掉。带框的检查点载荷从不渲染,且两条路径都按同一个标记对 surface 事件分类,因此实时到达的压缩与恢复后回放同一份日志会产生相同的记录。只有回放会重新推导 `tool/call` 的配对关系:调用事件自身不携带标记,其归属继承自公布它的 `assistant/message`,而实时监听器必然刚刚渲染过后者。 + +检查点通过压缩接缝自身的契约来识别——`isCompactCheckpointSource`,即 `CompactService` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。其他替换保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。 + +`session.history` 只把追加来源的消息计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compact/summary` 溯源信息会与引用它的替换留在同一页。 + +持久事件、RPC 信封、压缩事务与模型可见的 surface 都没有变化,也不需要迁移。 + +## Deferred + +浏览器客户端仍通过 `FoldAdapter` 从模型 surface 构建会话,因此压缩在 Web 端仍会把历史折叠成一行上下文。那里的修复用的是同一个谓词,另需按追加顺序的记录投影与一个标记组件;该工作是针对 `packages/client/runtime` 与 `packages/client/ui-conversation` 的独立变更。 + +该工作必须处理这样一页:其检查点引用的 `surfaceOp.start` 落在窗口之外。分页不再为检查点消耗额度,因此永远不会按检查点的溯源分组切分;而 `FoldAdapter` 会用一个非 surface 的哨兵事件填补缺失事件——于是 `SurfaceManager` 拒绝该范围,`nodes()` 退化为 `degradedSeqs()` 并记录一条错误。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页。`degradedSeqs()`——按追加顺序的每个 surface 可入事件——已经很接近 A2 所需的记录投影,因此那正是应当刻意构建的形态,而不是作为退化路径被动落到的结果。渲染压缩*进度*——压缩运行期间的终端指示——需要排队式手动 `/compact` 工作引入的“先开括号”顺序,同样不在本次范围内。标记同样不携带规模信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。这件事属于进度那一侧,读者正是在那里遇到同一份信息的另一半。接手者应当先把终端里两处替换分支——回放与实时监听器,文本完全相同却相隔 600 行——合并为一个 `renderReplacement(event)`,让标记的内容只有一个归处。 + +## Alternatives considered + +**按形态识别检查点(一个替换型 `user/message`)。** 被否决:那读取的是当前生产者的巧合而非已声明的契约,而未来任何用用户消息替换一段范围的生产者都会静默地继承压缩标记。接缝已经发布 `COMPACT_CHECKPOINT_SOURCE`,正是为了让消费方与后端无关地识别检查点。 + +**继续把检查点渲染为注入上下文卡片。** 被否决:带框的检查点是为模型撰写的指令信封,不是人类对话内容。展示它却隐藏它替换掉的历史,正好颠倒了读者的需要。 + +**持久化第二份展示用记录。** 被否决:仅追加的日志已经包含权威源材料,平行记录换不来任何东西,反而增加迁移与一致性工作。 + +**用 `compact/*` 括号而不是检查点来推导标记。** 就记录而言被否决:括号是围绕一次操作的一对时间点标记,而记录需要的是 surface 真正发生变化的位置。括号适合作为进度与耗时的来源,而本次变更并不渲染这些。 + +**像 `session-query` 为搜索所做的那样重新折叠日志来分类事件(`current`/`shadowed`/`log-only`)。** 被否决:折叠回答的是整份日志的问题,而投影问的是逐事件的问题,事件自身的标记已能以常数时间给出答案。 + +## Consequences + +压缩不再抹掉终端历史;被压缩多次的会话会按日志顺序显示每次落地压缩对应的一行标记。分页的每一页可以携带比以前更多的原始事件,因为额度只花在人类或模型真正产生的消息上。 + +`rebuildTranscript` 现在会为整份日志中的每个追加来源事件物化一个组件,并在挂载时、终端配色方案变化时以及每次切换 reasoning 时运行。压缩此前正好为压缩所服务的那些长会话限制了这项工作量,因此这份开销现在随会话长度增长,而不再随 surface 增长。这正是本次修复要做的取舍——保留历史才是目的——但窗口化或复用策略属于第一个真正测到重建变慢的人,而不属于日后某个疑惑工作量为何增长的性能分析者。 + +`dsh-tui` 为一个纯谓词新增了对 `dsh-compact` 接缝的依赖,与 `dsh-session-reference` 现有用法一致。终端在运行时仍然不需要任何压缩后端。 + +两项行为随其测试一起改变。表层替换的终端测试此前钉住的是抹除(“隐藏被遮蔽的工具调用”),现在钉住的是保留加恰好一行标记,其中被裁剪的结果副本、重新生成的 assistant 消息以及来自其他插件的替换都不渲染任何内容。压缩快照场景此前声称钉住压缩,却写入了 `workspace-context` 来源;现在它写入真实的检查点来源,并重新录制三份 fixture,以显示被保留的提示、完整的工具卡片和那行标记。 + +上文的实时/回放等价性由 fixture 钉住,而不只是在此断言:`surface-replayed-compaction` 在挂载时替换已经存在,其录制结果与实时路径的 `surface-after-compaction-wide` 逐字节一致。改动任一路径都会破坏这项相等——这正是要点:回放投影才是当初对用户造成回归的部分,两份 fixture 必须一起变动。 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index 437bd200dc..5669911951 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.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-06-30-session-store-fork-api.md -2026-06-30-session-store-fork-api.md: 5342deba8ca879026d32ee1420cb3c0fdf67c500 -2026-06-30-session-store-fork-api.zh.md: 51a3e0ce50aff10a9812c91d24dc6e78a56c43ba +2026-06-30-session-store-fork-api.md: 69ff85e1f137f4f263bf951af0a3f655411c606a +2026-06-30-session-store-fork-api.zh.md: 3304a6f384c9004b3572c95881f832a4aa21b77c diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md index 5342deba8c..69ff85e1f1 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md @@ -28,6 +28,12 @@ class SessionStore extends Service { An empty prefix is forkable; any non-empty boundary must be a safe existing sequence outside an open turn. Typed errors distinguish missing sources, stale objects, duplicate child ids, invalid boundaries, and prefixes ending during execution. Broader log validation and crash repair remain with their existing owners. +### Host and browser adaptation + +The Host `session.fork` RPC accepts `atSeq` as an anchor within the desired turn rather than as the store's inclusive safe boundary. It selects the first `turn/end` at or after that anchor; an omitted or past-end anchor selects the last completed turn. An anchor already in the log but not followed by a matching `turn/end` returns `fork-unavailable` and never falls back to an earlier turn, so a message action cannot silently omit the clicked message. + +The Host creates the child through the agent registry with the selected seed and lineage, and pre-publication setup installs the latest logged provider, model, and reasoning target before the child can run. It then attaches the child to the source Workspace. An attachment failure returns `workspace-attach-failed` with the already-published child id; the client reconciles that child into its summary list before surfacing the error. The Session-row action uses the last completed turn, while a message action supplies its event seq; both open the child after success, and lineage expansion makes it visible beneath the source. + ## Alternatives considered **Separate `ctx.sessionFork` service.** This was the first implementation, but review showed it overfit the capability-seam pattern. The code had no swappable backend, no extra event surface, no independent ownership lifecycle, and no durable behavior beyond `ctx.sessions.create({ seed, meta })`. Keeping a separate package would make callers discover and install a second service just to perform policy around a session-store primitive. @@ -40,4 +46,4 @@ An empty prefix is forkable; any non-empty boundary must be a safe existing sequ The public surface stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service or a two-step helper pair. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header. -The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md), while this API gets focused `dsh-session` unit tests plus JSONL persistence coverage. +The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md); focused store, Host, carrier, and client tests pin the boundary and reconciliation contracts, while the real Chromium scenario pins the assembled message action and lineage tree. diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md index 51a3e0ce50..3304a6f384 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -28,6 +28,12 @@ class SessionStore extends Service { 空前缀可以被 fork;任何非空边界都必须是位于开放轮次之外且安全、已存在的序号。类型化的错误区分源缺失、对象陈旧、子 id 重复、边界无效和前缀结束于执行过程中等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。 +### Host 与浏览器适配 + +Host 的 `session.fork` RPC 接受 `atSeq`,并将其视为所需轮次内的锚点,而非 store 中包含该序号的安全边界。它选择该锚点处或其后的首个 `turn/end`;锚点省略或超过末尾时,选择最后一个已完成轮次。若锚点已在日志中,但从该锚点起找不到匹配的 `turn/end`,则返回 `fork-unavailable`,绝不回退到更早的轮次,因此消息操作不会静默遗漏所点击的消息。 + +Host 通过 agent(智能体)注册表,以选定的种子和谱系创建子会话;发布前 setup 会先安装日志中最新的提供方、模型和推理(reasoning)目标,子会话才能运行。随后,Host 将子会话附加到源 Workspace。若附加失败,则返回 `workspace-attach-failed` 及已发布的子会话 id;客户端先将该子会话对账到摘要列表,再向调用方报告错误。Session 行操作使用最后一个已完成轮次,消息操作则提供其事件 seq;两者都会在成功后打开子会话,展开谱系后可在源会话下看到它。 + ## 曾考虑的替代方案 **独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在会话存储原语之上执行一层策略而去发现并安装第二个服务。 @@ -40,4 +46,4 @@ class SessionStore extends Service { 公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。 -v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才广播该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 则获得专门的 `dsh-session` 单元测试加 JSONL 持久化覆盖。 +v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才广播该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖;store、Host、载体与客户端的专项测试固定边界和对账契约,真实 Chromium 场景则固定组装后的消息操作与谱系树。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 0691864d6e..688fe6864a 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: 10564b110cc2e56615bde86830c0d39d5a7cee38 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 4624a4db3db598793eee257f829a080f4d7ad711 +2026-07-17-dedicated-full-screen-tui-front-door.md: c011a0284ea0efe59693785c038f814a866068ac +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5aea4ac0c5a3b29c098c297e514fab49caf643ff diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 10564b110c..c011a0284e 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -20,7 +20,7 @@ The selected front door receives the exact generated or resumed `SessionId` used ### Session projection and interaction -The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, including fenced code with hidden Markdown markers, a dim optional language label, and a code-colored body, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. +The TUI rebuilds the transcript from the append-origin session events, so resumed history keeps every message the reader already saw; a compacted range stays readable behind one marker instead of matching the model-visible conversation ([append-origin transcript](../bug-fix/2026-07-29-human-transcript-append-origin.md)). It renders Markdown text and reasoning, including fenced code with hidden Markdown markers, a dim optional language label, and a code-colored body, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; agent behavior and answer logging remain owned by their existing services. @@ -47,6 +47,6 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - Interactive terminal work has a stateful Markdown, card, plan, and question interface with no second terminal protocol to keep aligned. - The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. -- Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. +- Session projection makes resume consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. - Model and reasoning-effort selection use adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 4624a4db3d..5aea4ac0c5 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -20,7 +20,7 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README ### 会话投影与交互 -TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理(其中围栏代码块隐藏 Markdown 标记、保留一个暗色的可选语言标签,并使用代码配色的正文)、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 +TUI 从追加来源的会话事件重建 transcript(文本记录),因此恢复后的历史会保留读者已经看到的每条消息;被压缩的范围不再与模型可见会话保持一致,而是留在一行标记之后仍可阅读([追加来源的 transcript](../bug-fix/2026-07-29-human-transcript-append-origin.md))。TUI 渲染 Markdown 文本与推理(其中围栏代码块隐藏 Markdown 标记、保留一个暗色的可选语言标签,并使用代码配色的正文)、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。 @@ -47,6 +47,6 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - 交互式终端拥有带状态的 Markdown、卡片、计划和提问界面,无需再对齐第二套终端协议。 - TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 -- 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 +- 会话投影使恢复与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 - 模型和推理强度选择使用适配器公布的元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index 590d9227b8..ebc2df795a 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-session-list-browsing-and-manual-order.md: 586995bf459aeaee88672863977f7acf2a7061a3 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 432d5167a57d30bc04a0b4faf213e4341f07bd2f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +2026-07-25-session-list-browsing-and-manual-order.md: 831aa53e532a75392690c330837482bb0f9c32b1 +2026-07-25-session-list-browsing-and-manual-order.zh.md: 9ad074d59c13585aa4fca46ae4d40e2deb15cde6 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 586995bf45..831aa53e53 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -12,14 +12,14 @@ Two existing mechanisms stood in the way. First, the host durably promoted the a ## Decision -### Flat view and viewing state +### Flat rows and viewing state -The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders every session (fork children included) as a top-level row, strictly newest-first by `updatedAt`, with no parent/child adjacency; the Intent placeholder renders as the first row. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. +The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. ### Row interactions - Session rows show a detail card after a 500ms hover dwell (full title / relative time / status line; the status line has only running/idle until the wire grows a status field). The card and the row menu are mutually exclusive: no card while a menu is open or a drag is in flight. -- Session-row … menu: Rename / Fork session / Delete session, visual-only this iteration; workspace-header … menu: Rename (wired) / Delete workspace (visual-only). Menus close when the pointer leaves them. +- Session-row … menu: Rename / Fork session / Delete session; Rename and Fork are wired, while Delete remains visual-only. The workspace-header … menu's Rename / Delete workspace actions are both wired. Menus close when the pointer leaves them. - Supporting primitives: `Menu` gains label entries, danger rows, and `closeOnPointerLeave`; a new `HoverCard` (portaled placement, open delay, disabled guard). ### workspace.rename @@ -30,7 +30,7 @@ The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders e The `session/event` → `touchSession` activity-pinning chain is deleted wholesale; the workspace account order is now manually owned — new sessions prepend at attach, and explicit reordering goes through `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })` (DOM insertBefore semantics: with an anchor it inserts before it, omitted appends to the end). The entity throws a typed `WorkspaceMoveInvalidError` only for unaccounted session/anchor ids; the handler maps exactly that to the business code `workspace-move-invalid`, while storage failures stay internal. -The UI is HTML5 drag on root rows inside a group (workspace grouping only, outside search; fork children ride with their parent and are not draggable). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame. +The UI is HTML5 drag on session rows inside a group (workspace grouping only, outside search; fork children and their source sessions are ordered independently). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame. ### Shell/region split @@ -46,15 +46,15 @@ ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, **Keep the rename dialog in ui-sidebar (smallest change)** — that is the problem itself: workspace-domain dialogs scattered in a borrowed slot, with each addition (the Delete confirmation is coming) repeating the cross-package wiring. Review first considered moving only the rename modal; the ruling was to give the whole browsing region to ui-workspace and leave the shell geometry-only. -**Keep parent/child adjacency in flat mode** — contradicts strict recency (a child newer than its parent's sibling cannot slot adjacently), and the flat view's purpose is dropping the hierarchy; flattening fully and disabling drag in flat mode (no persistence carrier) is more consistent. +**Nest sessions by fork lineage in WorkSpace mode** — nesting makes the current child visible only while its ancestors are expanded and limits in-group manual ordering to root nodes; `parentId` is lineage data, not a list-navigation structure. Flattening all sessions into peer rows lets each row be opened, searched, and ordered independently; In one list still disables drag because it has no workspace persistence carrier. ## Consequences - Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. - The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. - Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. -- Wiring the three session-menu items and workspace Delete, and growing the wire status enum, remain future iterations. +- Wiring session Delete and growing the wire status enum remain future iterations. ## Testing -Package-level suites cover the derivations (deriveGroups/deriveFlat), row components, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application; delivery acceptance additionally runs a 12-item playwright (chromium headless) checklist (grouped default, flat switch and persistence, hover-card appearance and suppression, both menus, the full rename chain, drag persistence) and drives the real host over the wire for rename success / duplicate rejection / `workspace-move-invalid`. +Package-level suites cover the derivations (deriveGroups/deriveFlat), peer session rows, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application and pin that a fork does not introduce session expansion controls. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 432d5167a5..9ad074d59c 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -12,14 +12,14 @@ Status: implemented ## Decision -### 平铺视图与浏览态 +### 平铺行与浏览态 -group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所有 session(含 fork 子)一律作为顶层行,严格按 `updatedAt` 新→旧排序,不保持父子相邻;Intent 占位行渲染在列表首行。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 +group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 ### 行交互 - session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。 -- session 行 … 菜单:Rename / Fork session / Delete session,本期纯视觉;workspace 组头 … 菜单:Rename(已接线)/ Delete workspace(纯视觉)。菜单鼠标移出即关。 +- session 行 … 菜单:Rename / Fork session / Delete session,其中 Rename 与 Fork 已接线,Delete 仍为纯视觉;workspace 组头 … 菜单的 Rename / Delete workspace 均已接线。菜单鼠标移出即关。 - 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。 ### workspace.rename @@ -30,7 +30,7 @@ group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所 `session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新 session attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:锚给了插锚前,缺省 append 到末尾)。实体只对不在账的 session/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。 -UI 为组内 root 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子随父不单独拖)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。 +UI 为组内 session 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子与源会话一样独立排序)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。 ### 壳/区域切分 @@ -46,15 +46,15 @@ ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settin **rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。 -**平铺模式保持父子相邻成组** —— 与「严格按时间」矛盾(子新于兄则插不进相邻位),且平铺本意就是取消层级;拉平并禁用平铺下的拖拽(无持久化载体)更一致。 +**WorkSpace 模式按 fork 谱系嵌套 session** —— 嵌套会让当前子会话依赖祖先展开态才能可见,也让组内手动序只能移动根节点;`parentId` 是 lineage 数据,不是列表导航结构。所有 session 拍平成同级行后,每行都可独立打开、搜索与排序;In one list 仍因没有 workspace 持久化载体而禁用拖拽。 ## Consequences - 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。 - 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 - 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 -- session 菜单三项与 workspace Delete 的功能接线、状态枚举扩 wire,留待后续迭代。 +- session Delete 的功能接线与状态枚举扩 wire,留待后续迭代。 ## Testing -包级用例覆盖派生(deriveGroups/deriveFlat)、行组件、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用;交付验收另以 playwright(chromium headless)过 12 项清单(分组默认、平铺切换与持久化、hover 卡出现与抑制、双菜单、rename 全链、拖拽落盘),并对真 host 直打 wire 验证 rename 成功/重名拒绝/`workspace-move-invalid` 三径。 +包级用例覆盖派生(deriveGroups/deriveFlat)、同级 session 行、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用,并钉住 fork 后没有 session 展开控件。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml new file mode 100644 index 0000000000..e4b5cd7778 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md +2026-07-27-web-session-fork-actions.md: b5dc7e820de069a68b38ed87c7d29ffbdb4867bc +2026-07-27-web-session-fork-actions.zh.md: 774cd74d69eb02d43ca01c8ec7ba1cf94c24b1dc diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md new file mode 100644 index 0000000000..b5dc7e820d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md @@ -0,0 +1,33 @@ +# Agent Note: Web session fork actions + +Status: implemented + +English | [中文](2026-07-27-web-session-fork-actions.zh.md) + +## Problem + +The Session store already provides a fork primitive that creates a child session from a completed-turn prefix, but the Web client has no unified interaction contract. The Session-row menu can express only “branch from the latest completed turn,” while message IconActions need to express “branch from the turn containing this message”; if the two entry points independently interpret the boundary, switching, and failure behavior, the same user action acquires two sets of semantics. Nesting a fork child beneath its source session also makes the newly selected child visible only while its ancestors are expanded and weakens the workspace manual-order model. + +## Decision + +The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn containing that event. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. + +`forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. + +Session lineage is not projected into a list hierarchy. WorkSpace mode displays source sessions and all fork children as peer rows in the manual order from `WorkspaceView.sessionIds`; every row can be opened, searched, and dragged independently. In one list mode continues to sort strictly by `updatedAt`; the Ungrouped group also sorts by recency when no workspace ledger is available. `parentId` remains available for lineage, tool presentation, and later queries, but does not control session-list visibility. + +## Alternatives considered + +**Wire only the Session-row menu.** Rejected: at a message, the user has already selected more precise context; forcing them back to the list can only degrade the boundary to the latest completed turn, while the visible message branch icon would remain non-responsive. + +**Allow branching only from user messages.** Rejected: settled assistant content also has a stable event `seq`, and the host places it in its containing completed turn; making only one of two visually identical branch buttons work would create an invisible behavioral difference. + +**Nest fork children beneath their source by `parentId`.** Rejected: lineage is not navigation ownership; nesting requires automatic ancestor expansion to reveal the current item and prevents children from participating in the workspace's peer manual order. + +**Call the session service directly from message components.** Rejected: client components must not touch `ctx` or business services; injected callbacks keep mutation in the apply world and leave components driven purely by props. + +## Consequences + +Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. + +Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md new file mode 100644 index 0000000000..774cd74d69 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web session fork 操作 + +Status: implemented + +[English](2026-07-27-web-session-fork-actions.md) | 中文 + +## Problem + +Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 Web 端没有一份统一的交互契约。Session 行菜单只能表达「从最新完成轮分支」,消息 IconActions 还需要表达「从这条消息所在轮分支」;如果两处各自解释边界、切换与失败行为,同一个用户动作会形成两套语义。把 fork 子会话嵌套在源会话下还会让新选中的子会话依赖祖先展开态才能看见,并削弱 workspace 的手动排序模型。 + +## Decision + +Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在包含该事件的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 + +`forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 + +Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序把源会话与所有 fork 子会话显示为同级行,每行都可独立打开、搜索和拖拽;In one list 模式继续按 `updatedAt` 严格排序;Ungrouped 组在没有 workspace 账本时也按 recency 排序。`parentId` 仍用于 lineage、工具呈现和后续查询,但不控制 session 列表可见性。 + +## Alternatives considered + +**只接 session 行菜单。** 否决:用户在消息处已经选择了更精确的上下文,强迫其回到列表只能退化为最新完成轮,且已展示的消息分支图标会成为无响应控件。 + +**只允许用户消息分支。** 否决:已定稿 assistant 内容同样有稳定事件 `seq`,host 会把它归入所属完成轮;让两个外观相同的分支按钮只有一个可用会制造不可见的行为差异。 + +**按 `parentId` 把 fork 子会话嵌套在源会话下。** 否决:lineage 不是导航所有权;嵌套要求自动展开祖先才能看见当前项,并让子会话无法参与 workspace 的同级手动排序。 + +**由消息组件直接调用 session 服务。** 否决:client 组件不得接触 `ctx` 或业务服务;注入回调让 mutation 留在 apply 世界,组件保持纯 props。 + +## Consequences + +用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 + +Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq`、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index 85befa1383..d9b4a04671 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: 78a7d346163bb7e5e76c989c6e93576b4a6cee64 -2026-07-29-addressable-queue-operations.zh.md: 050b9755ad4ebe70e2bdcafb711ef279331e27af +2026-07-29-addressable-queue-operations.md: 7a08b889c958e583dc430d33a1855fe3725f3d48 +2026-07-29-addressable-queue-operations.zh.md: 701b028c7494fd7cb608d05a5d170c9075b155d7 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index 78a7d34616..7a08b889c9 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -18,7 +18,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. -**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock exposes edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. +**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. ## Alternatives considered @@ -34,7 +34,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M ## Verification -AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive the exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. +AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, interaction-forced visibility, reset after emptying, expansion, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios capture the default collapsed header before expanding the queue and driving its exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index 050b9755ad..701b028c74 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -18,7 +18,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 -**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 暴露编辑和删除,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 +**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 ## 考虑过的替代方案 @@ -34,7 +34,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 ## 验证 -AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除。 +AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、交互期间强制保持可见、清空后重置、展开、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会先捕获默认收起的表头,再展开队列,并通过构建后的 Web 组合和真实 HTTP/SSE 协议操作其公开的编辑和删除。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml new file mode 100644 index 0000000000..3ade5b93e1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md +2026-07-29-directory-picker-adaptive-default.md: 7ff6529bb8e445f63343b1019ac520f56b19d5e4 +2026-07-29-directory-picker-adaptive-default.zh.md: a2a2d8ec4c91a347eedfc3aa3413b091ee847934 diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md new file mode 100644 index 0000000000..7ff6529bb8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md @@ -0,0 +1,30 @@ +# Agent Note: Adaptive default for the directory-picker interaction + +Status: implemented + +English | [中文](2026-07-29-directory-picker-adaptive-default.zh.md) + +## Problem + +The [directory-picker seam](../architecture/2026-07-28-directory-picker-capability-seam.md) made the interaction a `cordis.yml` swap point, but the shipped composition still had to pin one backend: `-browse` everywhere meant a local operator never got the OS chooser, `-native` everywhere breaks every remote deployment. The right default depends on facts only the running host knows — where the server binds, whether the process was launched over SSH, whether a display session exists — so no static row is correct for all deployments. + +## Decision + +A third sibling package, **`dsh-host-directory-picker-auto`**: a node-half-only *chooser* that owns no picking code and no UI. Its `apply` samples the host facts exactly once at boot — bind host from the injected `httpServer` (a new `host` getter mirrors the existing `port`), `SSH_CONNECTION`/`SSH_TTY`, platform, `DISPLAY`/`WAYLAND_DISPLAY`, and a `PATH` probe for a Linux chooser binary (zenity/kdialog) — resolves them through one exported pure function, and mounts the chosen dual-face backend with `ctx.loader.create({name})` into the Loader's **in-memory root tree**; the effect's disposer removes the entry and joins the backend fiber's teardown (`remove()` alone only starts it), so unloading the chooser settles only after the backend quiesced. `native` requires every attended-and-servable signal: loopback bind ∧ no SSH markers ∧ a display session the native backend can drive — assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a chooser binary on linux, and never true elsewhere (the native backend supports exactly darwin/win32/linux). Anything ambiguous resolves to `browse`, which works everywhere. `apps/cli` now mounts `-auto` as its `directory-picker` row; composing `-native` or `-browse` directly remains the pin. + +Why entry-level mounting is the load-bearing mechanism: the client module table (`dsh-client-modules`) reconciles **Loader entries** reactively over `internal/plugin`, so a backend mounted as a real entry gets its browser half discovered exactly as a config-row's would be — the seam's one-row-swaps-both-faces invariant survives adaptivity with zero duplicated client code. The dev HMR row (`AppCLIEntry`) is the mechanism precedent. Root-tree targeting matters: the root tree's `write()` is a no-op, so the resolved row can never be persisted back into `cordis.yml` (the Include subtree *does* write). + +## Alternatives considered + +- **Boot-glue resolution in `AppCLIEntry`** (ship both rows with static `disabled`, patch `disabled` from a `--directory-picker=auto|native|browse` flag). Works — `PatchOptions` patches metadata, and the modules scan skips disabled rows — but leaves the decision app-private where every future composition re-implements it; the chooser plugin gives any `cordis.yml` the same one-row adaptivity. Reintroduce the flag only when a deployment needs to *force* a backend without editing its yml. +- **One merged plugin branching per call** (client tries `pick`, falls back to the browse dialog on `directory-picker-unavailable`). Rejected: the client would need both flows in one bundle — the bundle-purity gate forbids cross-plugin value imports and jscpd forbids copying the dialog — and per-call probing pays a doomed RPC on every open of a browse host. +- **Resurrecting the wire advertisement** so both client flows mount and branch on the host's kind. Rejected: reverses the seam note's deletion for no consumer the chooser doesn't already serve, and collides with the `single` directory-flow holes. +- **Per-connection adaptivity** (native for a loopback browser, browse for a remote one, same server). Deferred: needs a per-client capability, the advertisement above, and both flows mounted; no deployment serves both operator shapes at once today. + +## Consequences + +- The shipped web GUI adapts out of the box: attended local host → OS chooser; SSH launch, all-interfaces bind, headless host, unsupported platform, or Linux without a chooser binary → in-app browser. Detection infers operator location from launch context, which no launch-side signal can prove: a detached tmux session loses `SSH_*`; a non-Aqua darwin process still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, arriving from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation — per-connection adaptivity could not fix that last case either. A wrong `native` choice degrades to the backend's existing retryable failure dialog; deployments in these shapes compose `-browse` directly. +- The chooser mounts backends by runtime string (`BACKEND_PACKAGES`, exported), which yml-row scanning cannot see; `verify-cordis-config` therefore requires every composition mounting `-auto` to declare both backends as dependencies, so keyless Linux CI (which only ever resolves `browse`) cannot hide a dropped `-native` dependency. The shipped-tree web e2e/snapshot lane (`apps/web/tests/scaffold.ts`) pins `-browse` by disable+insert patch — its goldens are interaction-specific and must not depend on the host running the suite. +- One resolution per boot keeps the seam's capability-stability contract; per-connection shapes remain out of scope until a deployment demands them. +- Mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service; duplicate flow in the `single` holes). +- The host typecheck aggregate now references the two backend projects (declarations only, node entries carry no client merge) so the chooser's REAL-composition test can mount them — the mirror of the client aggregate's `webserver` reference. diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md new file mode 100644 index 0000000000..a2a2d8ec4c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md @@ -0,0 +1,30 @@ +# Agent Note:目录选择交互的自适应默认值 + +状态:已实现 + +[English](2026-07-29-directory-picker-adaptive-default.md) | 中文 + +## 问题 + +[目录选择 seam](../architecture/2026-07-28-directory-picker-capability-seam.md)把交互形态做成了 `cordis.yml` 的切换点,但随附的组合仍必须固定一个后端:处处用 `-browse` 意味着本地操作者永远得不到 OS 选择器,处处用 `-native` 则弄坏所有远程部署。正确的默认值取决于只有运行中的宿主才知道的事实——服务器绑定在哪里、进程是否经 SSH 启动、是否存在显示会话——因此没有哪一静态行对所有部署都正确。 + +## 决策 + +第三个同级包 **`dsh-host-directory-picker-auto`**:一个只有 node 半侧的*选择器*,不持有任何选取代码,也没有 UI。它的 `apply` 在启动时恰好采样一次宿主事实——从注入的 `httpServer` 读绑定宿主(新增的 `host` getter 与既有的 `port` 对称)、`SSH_CONNECTION`/`SSH_TTY`、平台、`DISPLAY`/`WAYLAND_DISPLAY`、以及对 Linux 选择器二进制(zenity/kdialog)的一次 `PATH` 探查——经由一个导出的纯函数判定,再用 `ctx.loader.create({name})` 把选中的双面后端挂进 Loader 的**内存根树**;该 effect 的 disposer 会移除该条目并汇入后端 fiber 的拆卸(单靠 `remove()` 只是启动拆卸),因此卸载选择器要到后端静止之后才落定。`native` 要求全部“有人值守且可服务”信号:回环绑定 ∧ 无 SSH 标记 ∧ native 后端能驱动的显示会话——darwin/win32 上视为存在,linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY` 外加一个选择器二进制,其余平台一律不成立(native 后端恰好支持 darwin/win32/linux)。任何含糊情形都判定为处处可用的 `browse`。`apps/cli` 现在把 `-auto` 挂为它的 `directory-picker` 行;直接组合 `-native` 或 `-browse` 仍是固定交互的方式。 + +条目级挂载之所以是承重机制:client 模块表(`dsh-client-modules`)基于 `internal/plugin` 对 **Loader 条目**做响应式协调,因此以真实条目挂载的后端,其 browser half 被发现的方式与配置行完全相同——seam 的“一行同时换两面”不变式在自适应下依然成立,且没有一行重复的 client 代码。开发环境的 HMR 行(`AppCLIEntry`)是该机制的先例。瞄准根树很关键:根树的 `write()` 是 no-op,因此判定出的行绝不会被持久化回 `cordis.yml`(Include 子树*会*写回)。 + +## 曾考虑的替代方案 + +- **在 `AppCLIEntry` 里做启动胶水判定**(随附两行并带静态 `disabled`,由 `--directory-picker=auto|native|browse` 标志修补 `disabled`)。可行——`PatchOptions` 能修补元数据,模块扫描也会跳过禁用行——但把决策留成应用私有,此后每个组合都要重新实现;选择器插件让任何 `cordis.yml` 都获得同样的一行自适应。只有当某个部署需要不改自己的 yml 就*强制*指定后端时,才重新引入该标志。 +- **合并成一个按调用分支的插件**(client 先试 `pick`,收到 `directory-picker-unavailable` 再回退到浏览对话框)。否决:client 得把两套流程装进同一个 bundle——bundle 纯净门禁禁止跨插件的值导入,jscpd 禁止复制对话框——而且按调用探测让 browse 宿主每次打开都付出一次注定失败的 RPC。 +- **复活 wire 广播**,让两套 client 流程都挂载并按宿主的 kind 分支。否决:推翻 seam Agent Note 的那次删除,却服务不了任何选择器尚未服务的消费方,还与 `single` 目录流洞相冲突。 +- **按连接自适应**(同一台服务器,回环浏览器用 native、远程浏览器用 browse)。延期:需要按客户端的能力对象、上述广播,以及同时挂载两套流程;今天没有部署同时服务两种操作者形态。 + +## 后果 + +- 随附的 web GUI 开箱即自适应:有人值守的本地宿主 → OS 选择器;SSH 启动、全网卡绑定、无头宿主、不支持的平台,或没有选择器二进制的 Linux → 应用内浏览器。探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点:脱离的 tmux 会话会丢失 `SSH_*`;非 Aqua 的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上——即便按连接自适应也修不了最后这一情形。错误的 `native` 选择会退化为后端既有的可重试失败对话框;处于这些形态的部署直接组合 `-browse`。 +- 选择器按运行时字符串(已导出的 `BACKEND_PACKAGES`)挂载后端,yml 行扫描看不到这一点;因此 `verify-cordis-config` 要求每个挂载 `-auto` 的组合把两个后端都声明为依赖,使无密钥的 Linux CI(它永远只会判定出 `browse`)无法掩盖被丢掉的 `-native` 依赖。随附树的 web e2e/快照通道(`apps/web/tests/scaffold.ts`)以 disable+insert 补丁固定 `-browse`——其 golden 是交互特定的,绝不能依赖运行该套件的宿主。 +- 每次启动只判定一次,维持 seam 的能力稳定性契约;按连接的形态在有部署提出需求前仍不在范围内。 +- 同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务;`single` 洞中的重复流程)。 +- host 类型检查聚合现在引用两个后端项目(仅声明,node 入口不携带 client 合并),使选择器的 REAL-composition 测试能挂载它们——与 client 聚合对 `webserver` 的引用互为镜像。 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index de8869a96a..f2e1183fe5 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: e79662056792c3ab413468ad038dec40455be767 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 72d3b4e0cda19438f2f46fd402b3b76de3726ae5 +2026-07-29-web-message-icon-actions-and-clock.md: ccc9b127d2fcf276fa415ec0ede8b062e45f3df5 +2026-07-29-web-message-icon-actions-and-clock.zh.md: fb4b82a22478b2de38755368ec48a5dca420e79d diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md index e796620567..ccc9b127d2 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -12,7 +12,7 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo **User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant *content* nodes (non-empty text blocks) append a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** -Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content; Think-only nodes and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. +Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content; Think-only nodes and the streaming tail omit the row. Copy writes joined text blocks. Both message rows pass their event's `seq` to the same fork callback; [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the real mutation contract. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. ## Alternatives considered @@ -22,10 +22,10 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day **Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate. -**Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat. +**Let the IconActions decision also define session fork semantics.** Rejected: this note owns only message chrome, clocks, and mount gating; boundary selection, failure behavior, and switching semantics belong to the separate [Web session fork actions](2026-07-27-web-session-fork-actions.md), keeping presentation components from becoming a second home for session mutation. **Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source. ## Consequences -Settled assistant content answers expose copy and the event clock as soon as the row mounts; Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, and the content-only assistant gate; the web e2e scenario pins the assembled IconActions chrome. +Settled assistant content answers expose copy, branch, and the event clock as soon as the row mounts; Think-only nodes stay chrome-free. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, the content-only assistant gate, and the respective event `seq` values passed by the user and assistant branch buttons; the web e2e scenario pins the assembled IconActions chrome. diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 72d3b4e0cd..fb4b82a224 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -12,7 +12,7 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 **用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant *内容*节点(非空 text 块)在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** -两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 +两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。两种消息行都把自己的事件 `seq` 交给同一个 fork 回调;真实 mutation 契约由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 ## 曾考虑的方案 @@ -22,10 +22,10 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 **在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。 -**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。 +**由 IconActions 决策同时定义 session fork 语义。** 否决:本笔记只拥有消息 chrome、时钟与挂载门控;边界选择、失败行为和切换语义属于独立的 [Web session fork 操作](2026-07-27-web-session-fork-actions.md),避免展示组件成为 session mutation 的第二正家。 **通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。 ## 后果 -已定稿的 assistant 内容回答在行挂载后立刻暴露复制与事件时钟;纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽与 assistant 仅内容门控;Web e2e 场景钉住组装后的 IconActions chrome。 +已定稿的 assistant 内容回答在行挂载后立刻暴露复制、分支与事件时钟;纯 Think 节点不带 chrome。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、assistant 仅内容门控,以及 user/assistant 分支按钮各自传递的事件 `seq`;Web e2e 场景钉住组装后的 IconActions chrome。 diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml new file mode 100644 index 0000000000..d6f77e3e14 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md +2026-07-30-plan-review-presentation-intent.md: aeab12aac308c24aa5c4b5953a60ae0f2cf6c5b5 +2026-07-30-plan-review-presentation-intent.zh.md: 4096018e374212c821675ce6ed3a2355df20edc9 diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md new file mode 100644 index 0000000000..aeab12aac3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md @@ -0,0 +1,55 @@ +# Agent Note: Plan review as a decision, not a question + +Status: implemented + +English | [中文](2026-07-30-plan-review-presentation-intent.zh.md) + +## Problem + +`exit_plan_mode` presents a finished plan for review through `ctx.userInteraction.ask()`, the same seam `ask_user_question` uses. On the Web GUI that made a plan review render as the generic question flow of [the ask-question Web presentation](2026-07-29-ask-question-web-presentation.md): a `1 / 1` pager, the plan as a question's supporting detail, the two verdicts as numbered radio rows with descriptions, an "Other — enter a custom answer" row, and `Skip this question` / `Submit` in the footer. + +Every one of those affordances is wrong for the surface. Reviewing a plan is one decision over one document, and the quiz chrome told the user they were being examined rather than asked to approve work — reported as "让人很困惑以为在做题". The paging controls page a set of one. Skipping is not an outcome the tool accepts (it folds into keep-planning). Worst, the surface gave no hint that this was the plan gate at all, while the adjacent waiting-approval takeover already had exactly the right shape for a decision: a tinted strip naming what is being decided, the subject as the body, and a right-aligned action row. + +## Decision + +A question may declare a **presentation intent**, and the Web composer renders a declared intent as its own surface. `AskUserQuestionItem` gains `intent?: AskUserQuestionIntent`, a tagged shape whose one member is `{ kind: 'plan-review', approve: string }`; `plan-mode` sets it on the review question, naming `Approve` as the label that approves. + +An intent shapes presentation only. The answer protocol is untouched: a UI honouring the intent answers with the same option labels a generic UI would send, so `exit_plan_mode` reads one answer shape regardless of which surface collected it, and a UI that does not know a tag renders the generic flow with nothing lost but the layout. + +`approve` names the affirmative option instead of relying on option order, so no UI infers a verdict from a position. Two assertions an intent makes are beyond the types, and `UserInteractionService.ask()` rejects both as `BAD_INTENT` at the asker: an `approve` naming none of that question's own options — before any UI can answer a choice never offered — and an intent on a question with no `detail`, the thing it declares itself a review of, which would ask the user to approve something invisible. On the wire the intent is a discriminated union, so an unrecognised tag is a rejected frame rather than a silently generic render. + +`ui-question` renders the intent as `PlanReviewPanel`, in the waiting-approval card language: the amber strip carries `Plan review`, the plan is the scrolling markdown body, and the decision row holds three actions — `Chat about it`, `Refuse`, `Approve`. The question text becomes the card's accessible name rather than a headline, because the buttons already say what the decision is. Approve and Refuse answer with the asker's own option labels and keep the asker's descriptions as tooltips; `Chat about it` cancels the request, which returns the composer so the user can simply say what they want. All copy is bilingual under the existing `question` namespace. + +Routing lives inside the single composer entry (`QuestionComposer` chooses the shape) rather than in a second chain registration, and `planReviewOf` claims a request only when the card can send every answer that request allows: one question declaring the intent, the plan as its `detail`, the named approve label offered, and a binary single choice — at most one option besides approve, and not multi-select. A third option or a multi-select batch has answers two buttons cannot express, so the generic flow keeps it, and keeps anything else the card cannot render. "Presentation only" is therefore literal: an intent never costs the user a reachable answer, and the client — downstream of a wire boundary — leaves every request answerable. + +Dismissal became its own model-facing outcome. `ASK_CANCELLED` previously reached the model as "the user cancelled ask_user_question", naming a tool it never called; `exit_plan_mode` now reports that the user dismissed the review to speak instead and to stay in plan mode and wait. Every other ask failure — an abort from turn cancel or provider teardown, where no user is coming — keeps its own message. + +## Alternatives considered + +**Make plan review its own pending kind (`plan-review/requested`).** Rejected as the wrong size for a presentation problem. It buys an honest response shape (approve / decline / discuss instead of an answer batch) at the cost of a third `PendingKind`, new requested/resolved frames and schemas, an api-proxy registry and respond branch, client session and baseline-replay handling, and a new three-package capability seam for a decision the question protocol already expresses. Worth revisiting only if plan review grows outcomes the answer shape cannot carry. + +**Route the card on the question's `id` or `header` (`plan-review` / `Plan review`).** Rejected: string-sniffing a foreign package's copy across a wire boundary, which any wording change silently breaks. The intent is the declaration that makes the routing legible. + +**Order the options and let the card read position 0 as approve.** Rejected: a positional contract at a package seam, invisible in both the type and the wire frame, and unenforceable — a producer that reorders its options would invert a user's verdict. Naming the label costs one string. + +**Register a second composer-chain entry for the plan card.** Rejected: two entries would select over the same pending question carrier, making the surface depend on chain priority and on whether the plan package's client half is composed at all. One entry that picks its own shape cannot race itself, and the generic flow is the built-in fallback. + +**Put the panel in `ui-plan` beside the plan chip.** Rejected: the panel's whole behavior is the question carrier's answer encoding (`PendingQuestion`), which `ui-question` owns; the intent is a question-protocol field, not plan-mode's private channel. Rendering declared intents belongs to the package that owns question rendering, as tool render intents belong to the tool renderer. + +**Extract a shared takeover card with `ui-conversation`'s `ApprovalPanel`.** Not done: the two takeovers agree on tokens and geometry but not on content — this body is scrolling markdown, that one a headline plus a command line — and the shared shell would be two elements wide. They are kept in step by token, not by component. + +**Give `Chat about it` its own protocol outcome.** Rejected: dismissing a request is a verb the generic flow already has (the `×` that cancels the batch). Promoting it to a labelled button is presentation; inventing a fourth wire outcome for it is not. + +## Consequences + +The question protocol now carries a presentation axis. Adding a second intent is a tag on the union, a producer that sets it, a schema member, and a panel — no new frame, service, or answer shape. The cost is that the question seam knows presentation exists at all, and that `ui-question` knows the word "plan"; both are the price of one entry owning every question surface. + +The plan gate reads as a plan gate: the plan is the card's content, the verdict is two labelled buttons, and taking the turn back is a third. The generic flow is untouched for every other question, and its committed goldens did not move. + +A deployment whose client half predates this change still shows the quiz layout — correct, answerable, and merely unstyled — because the intent is additive and the fallback is the generic flow. + +## Testing + +`ui-question` tests pin the narrowing (single-question batch, intent present, plan as detail, named approve label offered, binary single choice, decline absent when only approve is offered) and the panel (strip, markdown plan, accessible name, absence of pager/radio/skip/custom, approve and decline answering with the asker's labels, dismissal cancelling, one-shot latch with re-arm and message on a rejected receipt, tooltips present and absent, both locales). `user-interaction` tests pin both `BAD_INTENT` rejections and intent pass-through; `plan-mode` tests pin the declared intent against its own option list and both failure messages; the apiproxy schema test pins wire acceptance and an unknown tag's rejection. + +The `plan-review` Web e2e lane records `/plan` entering plan mode for real, the model calling `exit_plan_mode`, the decision card taking the composer (asserting the generic flow did **not** claim the request), and the card's own Approve completing the turn — two keyless goldens, the waiting card and the approved transcript. diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md new file mode 100644 index 0000000000..4096018e37 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md @@ -0,0 +1,55 @@ +# Agent Note:计划审阅是一次决定,不是一道题 + +Status: implemented + +[English](2026-07-30-plan-review-presentation-intent.md) | 中文 + +## 问题 + +`exit_plan_mode` 通过 `ctx.userInteraction.ask()` 把写好的计划交给用户审阅,而这正是 `ask_user_question` 使用的同一个 seam。在 Web GUI 上,这导致计划审阅渲染为[ask-question Web 呈现](2026-07-29-ask-question-web-presentation.md)里的通用问题流程:一个 `1 / 1` 分页器、计划作为问题的补充说明、两个裁决作为带描述的编号单选行、一行"其他,请填写自定义答案",以及底部的 `跳过本题` / `提交`。 + +这些可交互元素对这个界面而言无一正确。审阅一份计划是对一份文档做一次决定,而做题式的界面告诉用户他正在被考试,而不是被请求批准一份工作 —— 实际反馈是"让人很困惑以为在做题"。分页控件在给只有一项的集合分页。跳过并不是该工具接受的结果(它会折叠成继续规划)。最糟的是,这个界面完全没有暗示这就是计划关口,而旁边的等待审批接管早就具备了一次决定该有的形状:一条带色条带说明正在决定什么、主体是决定的对象、右对齐的操作行。 + +## 决定 + +一个问题可以声明**呈现意图(presentation intent)**,Web 输入区把已声明的意图渲染为它自己的界面。`AskUserQuestionItem` 新增 `intent?: AskUserQuestionIntent`,一个带标签的形状,目前唯一成员是 `{ kind: 'plan-review', approve: string }`;`plan-mode` 在审阅问题上设置它,并指明 `Approve` 是表示批准的标签。 + +意图只塑造呈现。回答协议不变:遵循意图的 UI 回答的仍是通用 UI 会发送的那些选项标签,因此无论由哪个界面收集,`exit_plan_mode` 读到的都是同一种回答形状;而不认识某个标签的 UI 渲染通用流程,除布局之外一无所失。 + +`approve` 指名肯定选项,而不依赖选项顺序,因此没有任何 UI 会从位置推断裁决。意图作出的两项断言超出类型的表达能力,`UserInteractionService.ask()` 都以 `BAD_INTENT` 在提问方一侧拒绝:`approve` 未命中该问题自身的任一选项 —— 早于任何 UI 回答一个从未被提供过的选择;以及意图落在没有 `detail` 的问题上,而 `detail` 正是它自称在审阅的东西,那会让用户去批准一件看不见的事。在协议格式(wire format)上意图是可辨识联合,因此无法识别的标签是被拒绝的帧,而不是静默退回通用渲染。 + +`ui-question` 把该意图渲染为 `PlanReviewPanel`,沿用等待审批卡片的语言:琥珀色条带写着 `Plan review`,计划是可滚动的 markdown 主体,决定行放三个操作 —— `Chat about it`、`Refuse`、`Approve`。问题文本成为卡片的无障碍名称而非标题,因为按钮已经说明了这次决定是什么。Approve 与 Refuse 用提问方自己的选项标签回答,并把提问方的描述保留为 tooltip;`Chat about it` 取消该请求,从而让输入区归位,用户直接说他想说的话即可。所有文案在既有 `question` 命名空间下双语。 + +路由住在单一输入区条目内部(由 `QuestionComposer` 选择形状),而不是第二个链式注册;`planReviewOf` 仅在卡片能够发出该请求允许的每一个答案时才接管:只有一个问题且声明了意图、以 `detail` 承载计划、提供了被指名的批准标签,且是二元单选 —— 除批准外最多一个选项,且非多选。出现第三个选项或多选批次时,其答案是两个按钮无法表达的,通用流程保留它,也保留其他任何卡片渲染不了的请求。因此"只塑造呈现"是字面意义上的:意图绝不让用户失去一个可达的答案,而位于协议边界下游的客户端让每个请求都保持可回答。 + +放弃审阅成为面向模型的独立结果。`ASK_CANCELLED` 以前传到模型的是"the user cancelled ask_user_question",指名了一个它从未调用的工具;现在 `exit_plan_mode` 报告用户放弃审阅是为了改用说话,并要求留在 plan mode 中等待。其余每一种 ask 失败 —— 轮次取消或提供方拆卸导致的中止,那里并没有用户会来 —— 保留它们自己的消息。 + +## 备选方案 + +**让计划审阅成为自己的待处理种类(`plan-review/requested`)。** 否决:对一个呈现问题来说尺寸不对。它换来的是诚实的响应形状(approve / decline / discuss 而非一批回答),代价是第三个 `PendingKind`、新的 requested/resolved 帧与 schema、一个 api-proxy 注册表与响应分支、客户端会话与基线重放处理,以及为一个问题协议已能表达的决定新增一个三包能力 seam。只有当计划审阅长出回答形状承载不了的结果时才值得重新考虑。 + +**按问题的 `id` 或 `header`(`plan-review` / `Plan review`)路由卡片。** 否决:这是跨协议边界嗅探另一个包的文案字符串,任何措辞改动都会静默破坏它。意图才是让路由可读的那个声明。 + +**约定选项顺序,让卡片把第 0 个位置读作批准。** 否决:这是包边界上的位置约定,在类型和协议帧里都看不见,也无法强制 —— 生产方一旦重排选项,就会颠倒用户的裁决。指名标签只花一个字符串。 + +**为计划卡片注册第二个输入区链条目。** 否决:两个条目会对同一个待回答问题载体做选择,使界面取决于链优先级、以及计划包的客户端半边是否被组合。一个自己挑形状的条目不会和自己抢,而通用流程正是内建的回退。 + +**把面板放在 `ui-plan` 里、紧挨计划状态标签。** 否决:面板的全部行为就是问题载体的回答编码(`PendingQuestion`),那是 `ui-question` 拥有的;意图是问题协议的字段,不是 plan-mode 的私有通道。渲染已声明的意图属于拥有问题渲染的那个包,正如工具渲染意图属于工具渲染方。 + +**与 `ui-conversation` 的 `ApprovalPanel` 抽出共享的接管卡片。** 未做:两个接管在 token 和几何上一致,但内容不一致 —— 这边的主体是可滚动 markdown,那边是一行标题加一行命令 —— 共享外壳只会剩两个元素宽。它们靠 token 保持一致,而不是靠组件。 + +**给 `Chat about it` 自己的协议结果。** 否决:放弃一个请求是通用流程已有的动词(取消整批的 `×`)。把它提升为带标签的按钮属于呈现;为它发明第四种协议结果不属于。 + +## 结果 + +问题协议从此带有一个呈现轴。新增第二个意图 = 联合上的一个标签、一个设置它的生产方、一个 schema 成员、一个面板 —— 不需要新的帧、服务或回答形状。代价是问题 seam 从此知道"呈现"这件事存在,且 `ui-question` 知道"plan"这个词;两者都是由单一条目拥有全部问题界面所要付的价钱。 + +计划关口读起来就像计划关口:计划是卡片的内容,裁决是两个带标签的按钮,把轮次拿回来是第三个。通用流程对其他每个问题都未受影响,其已提交的 golden 也没有变动。 + +客户端半边早于本次改动的部署仍然显示做题式布局 —— 正确、可回答、只是没有专门样式 —— 因为意图是增量的,而回退就是通用流程。 + +## 测试 + +`ui-question` 测试钉住收窄(单问题批、意图存在、计划作为 detail、被指名的批准标签确实被提供、二元单选、只提供批准时 decline 缺席)与面板(条带、markdown 计划、无障碍名称、无分页/单选/跳过/自定义、批准与拒绝用提问方的标签回答、放弃触发取消、一次性闭锁在回执被拒时重新武装并给出消息、tooltip 有与无、两种语言)。`user-interaction` 测试钉住两种 `BAD_INTENT` 拒绝与意图透传;`plan-mode` 测试钉住已声明的意图与其自身选项列表的一致、以及两条失败消息;apiproxy schema 测试钉住协议接受与未知标签的拒绝。 + +`plan-review` Web e2e 通道录制了 `/plan` 真实进入 plan mode、模型调用 `exit_plan_mode`、决定卡片接管输入区(并断言通用流程**没有**接管该请求)、以及卡片自身的 Approve 完成该轮 —— 两份无密钥 golden:等待中的卡片与批准后的会话记录。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index ba77f5f044..88d32203c0 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: 983d5520bd73fc3cf82c37bf0d4a9ff1c6e6f51c -2026-07-22-evidence-based-larger-hosted-runners.zh.md: a86dcf2c60d7b950e7557e84ef6993e712a2ce09 +2026-07-22-evidence-based-larger-hosted-runners.md: d46b8291ec05e997728da76354354f9e36bd2fb4 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: e05ad30a713258ed7bc3d8099f8d6fab3d7c0c5d diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 983d5520bd..d46b8291ec 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -18,9 +18,9 @@ The required primary path depends on those enterprise pools. Standard GitHub-hos The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from Oxlint discovery because the artifact check removes them while these processes overlap. The pnpm store is restored without putting cache uploads on the pull-request critical path; Oxlint has no repository-managed result cache. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler owns source and documentation gates that do not consume emitted output. The third job owns the single Linux build, then starts lint, Node 24 runtime compatibility, build-backed snapshots, documentation typechecking, and all artifact consumers against that tree. This [independent consumer build](2026-07-30-independent-ci-consumer-build.md) lets all three jobs request runners immediately without duplicating compilation or transferring a run-scoped artifact. Generated NodeNext consumer directories are excluded from Oxlint discovery because the artifact check removes them while these processes overlap. The pnpm store is restored without putting cache uploads on the pull-request critical path; Oxlint has no repository-managed result cache. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. -The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking builds its complete project-reference graph once. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count. +The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking consumes the consumer lane's complete project-reference output. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count. The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails. @@ -68,7 +68,7 @@ An additional serial Linux reference runs on the in-house self-hosted pool (`vm- **Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it. -**Keep static gates and post-build consumers on one runner.** Reusing one workspace avoids a setup wave and artifact transfer, but build-duration variance delays every consumer and leaves their lint and snapshot tails after the static result. A run-scoped built tree preserves one exact build while independent jobs keep both complete paths within the observed target. +**Publish the static job's build to post-build consumers.** A run-scoped artifact preserves one exact build, but the workflow can only consume it by waiting for the entire static job and then requesting another runner. The [independent consumer build](2026-07-30-independent-ci-consumer-build.md) assigns the single Linux build to its actual consumers instead. **Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path. @@ -80,7 +80,7 @@ An additional serial Linux reference runs on the in-house self-hosted pool (`vm- The required topology pays one setup wave per 32-core lane and retains no shard selectors. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful. -GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice and transfers one built tree, but isolates coverage, static gates, and post-build consumers from each other's critical paths without repeating the build; consolidating Windows avoids repeating its slower setup. +GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice, but the consumer lane owns the only built tree and coverage, static gates, and post-build consumers enter runner allocation independently; consolidating Windows avoids repeating its slower setup. Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index a86dcf2c60..e05ad30a71 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -18,11 +18,11 @@ Status: implemented 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 Oxlint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 会得到恢复,但缓存上传不会进入拉取请求关键路径;Oxlint 没有由仓库管理的结果缓存。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器负责不消费生成输出的源码和文档门禁。第三个作业负责唯一一次 Linux 构建,随后让 lint、Node 24 运行时兼容性、依赖构建产物的快照、文档类型检查和所有产物消费方基于该目录树启动。这种[消费方独立构建](2026-07-30-independent-ci-consumer-build.md)使 3 个作业都能立即请求运行器,而无需重复编译或传输仅供本次运行使用的产物。生成的 NodeNext 消费方目录不会纳入 Oxlint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 会得到恢复,但缓存上传不会进入拉取请求关键路径;Oxlint 没有由仓库管理的结果缓存。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 -门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查只构建一次完整的 project-reference 图。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。 +门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查以消费方通道的完整 project-reference 输出为输入。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。 -产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布契约只要遗漏一个运行时分片,检查仍会失败。 +产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包(package)启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布契约只要遗漏一个运行时分片,检查仍会失败。 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 @@ -68,7 +68,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 **让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。 -**将静态门禁和构建后消费方保留在同一台运行器上。** 复用同一个工作区可以省去一轮设置和一次产物传输,但构建耗时的波动会延迟每个消费方,并使消费方的 lint 和快照尾段延续到静态结果之后。仅供本次运行使用的已构建目录树可以保留同一份构建结果,而相互独立的作业能让两条完整路径都保持在实测目标内。 +**将静态作业的构建发布给构建后消费方。** 仅供本次运行使用的产物能保留同一份构建结果,但工作流要消费它,只能先等待整个静态作业完成,再请求另一台运行器。[消费方独立构建](2026-07-30-independent-ci-consumer-build.md)则转而让实际消费方负责唯一一次 Linux 构建。 **将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。 @@ -80,7 +80,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 必需拓扑中的每个 32 核通道只承担 1 轮设置开销,且不保留分片选择器。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows 运行器分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。 -GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置并传输一份已构建目录树,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径,且无需重复构建;合并 Windows 则避免重复其耗时更长的设置。 +GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置,但消费方通道拥有唯一一份已构建目录树,且覆盖率、静态门禁与构建后消费方分别进入运行器分配;合并 Windows 则避免重复其耗时更长的设置。 性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。 diff --git a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.i18n.yaml new file mode 100644 index 0000000000..9b7308098b --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.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/process/2026-07-30-cordis-config-source-plane-resolution-gate.md +2026-07-30-cordis-config-source-plane-resolution-gate.md: f9070d39559948ef27f96df5afccd7c4e076f131 +2026-07-30-cordis-config-source-plane-resolution-gate.zh.md: fac6c4047d334d7dd0685aa270234fee3d15dba8 diff --git a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.md b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.md new file mode 100644 index 0000000000..f9070d3955 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.md @@ -0,0 +1,27 @@ +# Agent Note: verify-cordis-config gates source-plane resolution of configured plugins + +Status: implemented + +English | [中文](2026-07-30-cordis-config-source-plane-resolution-gate.zh.md) + +## Problem + +`apps/cli/config/tui.cordis.yml` gained the `@deepseek-ai/dsh-tui/prompt` entry without a matching tsconfig `paths` mapping. The generic `@deepseek-ai/dsh-*` wildcard substitutes `tui/prompt` whole into its `/*/src` candidates, none of which exist, so the [tsx source launch](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) fell back to package `exports` and resolved `lib/prompt.js` — an artifact-plane file. Every environment with a built `lib/` (developer trees after `pnpm build`) booted fine, and the e2e workflow runs the keyless TUI PTY smoke in `lib` mode (`DSH_EXAMPLE_MODE=lib`, built bin under plain Node) so CI never exercises the source vector at all — while every clean checkout failed `pnpm dsh` at startup with `plugin(s) failed to load: @deepseek-ai/dsh-tui/prompt`. No gate checked the source plane, so the breakage shipped silently and surfaced only in fresh worktrees. + +## Decision + +`scripts/verify-cordis-config.ts` (`validateSourcePlaneResolution`) requires every configured specifier of a local workspace package — harness packages and vendored Cordis alike — to resolve through the `tsconfig.base.json` `paths` facade to a `.ts`/`.tsx` source file, using `ts.resolveModuleName` from the repository root. A failed resolution or a `.d.ts` hit (the `exports` fallback into built `lib/types`) fails `verify-cordis-config`, naming the config files and the specifier. The missing `@deepseek-ai/dsh-tui/prompt` mapping is added next to the other explicit subpath entries; removing it reproduces the gate failure. + +## Alternatives considered + +**Rely on the keyless TUI PTY smoke.** In default source mode it boots the real tree through the source vector and does catch the failure — but only on a clean tree. CI's e2e workflow runs it exclusively in `lib` mode (the built bin resolving real package `exports`), so no CI line runs the source vector, and developer trees with a stale `lib/` stay masked locally. Adding a source-mode CI smoke proves one composition per run; the static gate covers every shipped and example config. + +**Broaden the `dsh-source-launch-smoke` compat test to full boot.** The node-compat smoke asserts only the TTY refusal, which happens before plugin loading. A full keyless boot per matrix line duplicates the PTY smoke at higher cost and, like it, proves one composition rather than every shipped and example config. + +**A `@deepseek-ai/dsh-*/prompt`-style wildcard mapping.** Fixes this one subpath but not the class; the next single-file subpath export (`/surface`, `/message`, …) regresses identically. The static gate covers all current and future configured specifiers. + +## Consequences + +- A configured workspace specifier that resolves only through built `lib/` is now a red `verify-cordis-config` (in `hygiene` and CI) instead of a clean-tree-only startup crash. +- New single-file subpath exports referenced from a cordis.yml need an explicit `tsconfig.base.json` `paths` entry at introduction time; the gate message says so. +- The gate resolves with `tsconfig.base.json` options only; a specifier needing client-only compiler options to resolve would fail it, which matches the facade's role as the single resolution surface for tsx and vitest. diff --git a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md new file mode 100644 index 0000000000..fac6c4047d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md @@ -0,0 +1,27 @@ +# Agent Note: verify-cordis-config 对配置中插件的源码面解析实施门禁 + +Status: implemented + +[English](2026-07-30-cordis-config-source-plane-resolution-gate.md) | 中文 + +## 问题 + +`apps/cli/config/tui.cordis.yml` 新增了 `@deepseek-ai/dsh-tui/prompt` 配置项,却没有对应的 tsconfig `paths` 映射。通用的 `@deepseek-ai/dsh-*` 通配符会把 `tui/prompt` 整体代入其 `/*/src` 候选路径,而这些路径全都不存在,因此 [tsx 源码启动](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)会回退到包(package)的 `exports`,解析出产物面文件 `lib/prompt.js`。任何带有已构建 `lib/` 的环境(开发者目录树运行 `pnpm build` 后)都能正常启动,而 e2e 工作流以 `lib` 模式(`DSH_EXAMPLE_MODE=lib`,构建产物 bin 在普通 Node 下运行)执行无密钥 TUI PTY 冒烟测试,因此 CI 根本不会经过源码启动向量——与此同时,所有干净检出环境中的 `pnpm dsh` 都会在启动时失败,并报错 `plugin(s) failed to load: @deepseek-ai/dsh-tui/prompt`。当时没有门禁检查源码面,因此该故障未被发现便进入发布版本,仅在新的 worktree 中暴露。 + +## 决策 + +`scripts/verify-cordis-config.ts`(`validateSourcePlaneResolution`)要求配置中凡是引用本地 workspace 包的模块说明符(包括 harness 包与纳入 vendor 的 Cordis)都必须通过 `tsconfig.base.json` 的 `paths` 外观层(facade)解析到 `.ts`/`.tsx` 源文件;解析以仓库根目录为起点,调用 `ts.resolveModuleName` 完成。解析失败或命中 `.d.ts`(即经 `exports` 回退到构建出的 `lib/types`)都会使 `verify-cordis-config` 失败,并列出配置文件与模块说明符。缺失的 `@deepseek-ai/dsh-tui/prompt` 映射已添加在其他显式子路径条目旁;删除该映射即可复现门禁失败。 + +## 备选方案 + +**依赖无密钥 TUI PTY 冒烟测试。** 在默认源码模式下,该测试通过源码向量启动真实目录树,确实能捕获这个故障,但仅限干净目录树。CI 的 e2e 工作流只以 `lib` 模式运行它(构建产物 bin 通过真实的包 `exports` 解析),因此没有任何 CI 环节执行源码向量,而带有过期 `lib/` 的开发者目录树在本地也仍被掩盖。为 CI 增加一个源码模式冒烟测试,每次也只能证明一种组合;静态门禁则覆盖所有随产品发布的配置与示例配置。 + +**将 `dsh-source-launch-smoke` 兼容性测试扩展为完整启动。** node-compat 冒烟测试只断言 TTY 拒绝,而该拒绝发生在插件加载之前。每条矩阵版本线都执行一次完整的无密钥启动,会以更高成本重复 PTY 冒烟测试,而且同样只能验证一种组合,无法覆盖所有随产品发布的配置与示例配置。 + +**使用类似 `@deepseek-ai/dsh-*/prompt` 的通配符映射。** 这能修复当前子路径,却不能杜绝这一类问题;下一个单文件子路径导出(`/surface`、`/message` 等)仍会以同样方式复发。静态门禁覆盖当前及未来配置中引用的所有模块说明符。 + +## 结果 + +- 配置中的 workspace 模块说明符若只能通过构建后的 `lib/` 解析,现在会导致 `verify-cordis-config` 门禁失败(在 `hygiene` 和 CI 中执行),而不再成为只在干净目录树中出现的启动崩溃。 +- cordis.yml 中引用新的单文件子路径导出时,必须同步为 `tsconfig.base.json` 添加显式 `paths` 条目;门禁消息会明确提示这一要求。 +- 门禁只使用 `tsconfig.base.json` 的选项执行解析;如果某个模块说明符需要仅客户端可用的编译器选项才能解析,门禁就会失败。这符合该外观层作为 tsx 与 vitest 唯一解析入口的定位。 diff --git a/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.i18n.yaml new file mode 100644 index 0000000000..663b074d59 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.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/process/2026-07-30-independent-ci-consumer-build.md +2026-07-30-independent-ci-consumer-build.md: ea87d8051a30c282bdf57ddc3226072be8cb7f24 +2026-07-30-independent-ci-consumer-build.zh.md: 1b5faf73711bd522ddf0ae04f870e0401a481c0e diff --git a/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.md b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.md new file mode 100644 index 0000000000..ea87d8051a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.md @@ -0,0 +1,35 @@ +# Agent Note: Independent CI consumer build + +Status: implemented + +English | [中文](2026-07-30-independent-ci-consumer-build.zh.md) + +## Problem + +The [larger-runner topology](2026-07-22-evidence-based-larger-hosted-runners.md) gave the static and built-consumer inventories separate jobs, but the static job owned their shared build. It uploaded the emitted tree only after every static gate completed, and the consumer job declared a job-level dependency before restoring that tree. Compiled-output snapshots and publication checks genuinely require a complete build; they do not require runtime-closure checks, documentation generation, module-graph verification, or Knip. + +That wider dependency made runner availability part of the required critical chain. In one failover run, static waited 8 minutes 1 second for a runner and ran for 1 minute 41 seconds; only then could consumers enter the same shared pool, where they waited another 10 minutes 34 seconds before running for 1 minute 58 seconds. Reusing the static build saved repository work but serialized two independent runner allocations. + +## Decision + +The three required Linux jobs enter runner allocation independently. Coverage remains source-only. Static owns source and documentation checks that do not consume emitted output. The consumer job owns the single Linux build together with documentation typechecking, compiled-output snapshots, publication checks, NodeNext checks, and built-bin smokes. + +The consumer's internal gate graph preserves the real dependency. Build and source-only Node compatibility start first; publint waits for build, built-package invariants validate that publication view, and every compiled-output consumer waits for that validation. Example and Web snapshots therefore continue to exercise current `lib/` output under plain Node, while no GitHub job waits for an unrelated job or transfers a built-tree artifact. + +Windows and serial reference aggregates retain their own build ownership. The change is confined to the required pull-request Linux topology; `all checks passed` still aggregates the same named jobs and fails for any unsuccessful dependency. + +## Alternatives considered + +**Keep publishing the static job's build.** This preserves one build but cannot express the actual step-level dependency: GitHub makes the consumer wait for the whole static job before it can request a runner. The saved build time is smaller than the repeated queue delay during failover saturation. + +**Build independently in both jobs.** Removing the job dependency while leaving build in static would restore parallel allocation, but every pull request would compile the same tree twice. Moving documentation typechecking and build ownership to the consumer preserves one build. + +**Add a dedicated build job.** A narrow producer would make the dependency name accurate, but it would add a fourth setup and runner-allocation stage before consumers. The consumer already owns every long-lived use of emitted output, so a separate producer has no second independent consumer. + +**Combine static and consumers only during failover.** One long job would avoid the second allocation, but conditional job inventories and result aggregation would create a second CI topology. Independent jobs preserve the same graph on hosted and failover pools. + +## Consequences + +Static and consumer queue delays overlap instead of accumulating. The consumer's active time includes the build, while the static job becomes shorter and artifact upload, download, compression, and extraction disappear. Total Linux build count remains one. + +A static failure no longer prevents the consumer inventory from producing its own evidence; the final verdict still fails. Build and documentation-typecheck failures appear under `node 24 / snapshots and artifacts` rather than `node 24 / static`, matching the job that owns their output dependency. diff --git a/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.zh.md b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.zh.md new file mode 100644 index 0000000000..1b5faf7371 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 消费方独立构建 + +Status: implemented + +[English](2026-07-30-independent-ci-consumer-build.md) | 中文 + +## 问题 + +[大型运行器拓扑](2026-07-22-evidence-based-larger-hosted-runners.md)将静态门禁清单和构建后消费方清单分配给不同作业,但二者共用的构建由静态作业负责。静态作业要等所有静态门禁完成后才上传生成的目录树,消费方作业则在恢复该目录树前声明了作业级依赖。基于编译输出的快照与发布校验确实需要完整构建,但不依赖运行时依赖闭包检查、文档生成、模块图验证或 Knip。 + +这项过宽的依赖使运行器可用性成为必需关键链的一环。一次故障切换运行中,静态作业等待运行器 8 分 1 秒,随后运行 1 分 41 秒;直到此时,消费方作业才能进入同一个共享池,它又等待 10 分 34 秒,随后运行 1 分 58 秒。复用静态作业的构建省去了部分仓库工作,却让两次原本相互独立的运行器分配串行发生。 + +## 决策 + +3 个必需 Linux 作业分别进入运行器分配。覆盖率仍只消费源码。静态作业负责无需消费生成输出的源码检查与文档检查。消费方作业负责唯一一次 Linux 构建,以及文档类型检查、基于编译输出的快照、发布校验、NodeNext 检查和 built-bin 冒烟测试。 + +消费方内部的门禁图保留实际依赖关系。构建和只消费源码的 Node 兼容性检查率先启动;publint 等待构建完成,已构建包不变式检查会验证该发布视图,所有编译输出消费方都等待这项验证完成。因此,示例和 Web 快照仍会在普通 Node 下验证当前 `lib/` 输出;同时,没有任何 GitHub 作业需要等待无关作业或传输已构建目录树产物。 + +Windows 与串行参考聚合流程仍各自负责自身构建。本变更仅涉及拉取请求的必需 Linux 拓扑;`all checks passed` 仍聚合同一批具名作业,任一依赖未成功时都会失败。 + +## 曾考虑的替代方案 + +**继续发布静态作业的构建。** 此方案只需构建一次,却无法表达实际的步骤级依赖:GitHub 会让消费方等到整个静态作业结束后才可请求运行器。故障切换池饱和时,再次排队的延迟超过了省下的构建时间。 + +**在两个作业中分别独立构建。** 在静态作业中保留构建、同时移除作业依赖,可以恢复并行分配,但每个拉取请求都会对同一目录树编译两次。将文档类型检查和构建职责移给消费方,则仍只需构建一次。 + +**新增专用构建作业。** 职责单一的生产方能让依赖名称与实际关系相符,但会在消费方之前新增第 4 个需要设置和分配运行器的阶段。所有需要持续使用生成输出的任务都已由消费方作业负责,因此单独增设生产方也没有第二个相互独立的消费方。 + +**仅在故障切换期间合并静态作业与消费方作业。** 单个长作业可以避免第二次分配,但带条件分支的作业清单与结果聚合会形成第二套 CI 拓扑。独立作业能让托管池与故障切换池使用同一作业图。 + +## 后果 + +静态作业与消费方作业的排队延迟会相互重叠,不再累加。消费方的活动耗时包含构建;静态作业则变短,产物上传、下载、压缩和解压步骤全部消失。Linux 构建总次数仍为 1 次。 + +静态作业失败不再阻止消费方清单生成自身证据;最终判定仍会失败。构建与文档类型检查失败会归入 `node 24 / snapshots and artifacts` 而非 `node 24 / static`,这一归类与输出依赖的实际归属一致。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 14990e32c8..ada8ff7a3e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: fb28b7013550a853b92e50810f5bc34f2c02d2e4 -2026-07-24-web-gui-browser-e2e-lane.zh.md: b9a7d050031c3d08269a3b971cd1a84f082efba7 +2026-07-24-web-gui-browser-e2e-lane.md: 107dbddbfde8ad29e22d9cba04ce2b83c1d01383 +2026-07-24-web-gui-browser-e2e-lane.zh.md: e4132b2ebb3f30a9d540f47cf9416a13bc4aa9f3 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index fb28b70135..107dbddbfd 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -16,7 +16,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. -`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. +`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`; every host-level `skill-local` root (`dshHome`, `agentsHome`, and `bundledSkillDir`) pinned beneath the temp workspace with watching disabled, because ambient skill catalogs are model-visible input; `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md); `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor); the webserver row pinned to port 0 with the built dist; and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### CI stance -The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The static job publishes `apps/web/dist` with the package build artifacts; the `node 24 / snapshots and artifacts` consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The hosted and self-hosted default-branch Linux serial jobs run the same gate; the hosted job produces the browser cache consumed by pull requests, while the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. +The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The hosted and self-hosted default-branch Linux serial jobs run the same gate; the hosted job produces the browser cache consumed by pull requests, while the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. ## Prior art @@ -76,7 +76,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot ## Testing -`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. +`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. The scaffold hermeticity scenario populates distinct entries in all three ambient skill roots and requires none to enter the assembled catalog. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. ## Deferred diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index b9a7d05003..e4132b2ebb 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -16,7 +16,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 -`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 +`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;每个主机级 `skill-local` 根目录(`dshHome`、`agentsHome` 和 `bundledSkillDir`)都钉在临时工作区下并禁用监听,因为环境 skill(技能)目录是模型可见输入;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;`node 24 / snapshots and artifacts` 消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动契约](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动契约](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 ## 业界先例 @@ -76,7 +76,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## Testing -`pnpm run test:web` 构建并无密钥运行该车道;`test:web:built` 基于现有构建产物运行。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 +`pnpm run test:web` 构建并无密钥运行该车道;`test:web:built` 基于现有构建产物运行。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。scaffold 环境隔离场景会在全部 3 个环境 skill 根目录中分别填入不同条目,并要求这些条目都不得进入组装后的目录。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 ## 暂缓 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index d16d412559..2e8e53fdd7 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.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/testing/2026-07-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: 3f87bb0f3d936bcee7ba7c3d84ae808c6ede1a97 -2026-07-30-web-browser-snapshot-ci-gate.zh.md: af563f0e2a1c20f7b53d371e97e41b7ffa52a1d1 +2026-07-30-web-browser-snapshot-ci-gate.md: 14402485034cd85ec5781477ce67481165d47e62 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: f214c524253d2ad8a43e8543dc65ddfcfd7c065c diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index 3f87bb0f3d..1440248503 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -12,7 +12,7 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. -The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions. +The consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions. Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. @@ -26,10 +26,10 @@ An observed self-hosted consumer run measured `web-snapshot` at 112.15 seconds a **Run CI in `refresh` mode and then check the working tree.** Rejected: checking after writing turns the assertion mechanism into a generator; if the working-tree check is wired incorrectly, it can turn a regression into a passing expected-output update. Replay compares the existing goldens directly and has a smaller failure surface. -**Create a standalone browser job and rebuild the entire repository.** Rejected: it would duplicate dependency installation and the publishable build. The existing Linux consumer job already consumes the same built-tree artifact and is part of the unified required verdict. +**Create a standalone browser job and rebuild the entire repository.** Rejected: it would duplicate dependency installation and the publishable build. The existing Linux consumer job already owns that build and is part of the unified required verdict. **Replace real Chromium with jsdom snapshots.** Rejected: jsdom does not cover the browser, HTTP/SSE carriage, or the composition of real client plugin bundles. It remains useful for fast lower-layer feedback, but cannot replace the assembled browser chain. ## Consequences -Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in the consumer job; built-artifact reuse and the browser cache avoid duplicate builds and downloads on reruns. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. +Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index af563f0e2a..f214c52425 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -12,7 +12,7 @@ Status: implemented Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts` 把 `test:web:built` 作为 `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 -静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 和包的 `lib/` 目录放进 built-tree 产物,消费方 job 复用该产物而不重复全仓构建。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。 +消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。 本地 `pnpm run test:web` 仍先构建再运行浏览器全集;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处 expected diff,再以 replay 模式复验不再写文件。 @@ -26,10 +26,10 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览 **让 CI 以 `refresh` 模式运行后检查工作树。** 已否决:写后比较把断言机制变成生成器,若工作树检查接线失效就会把回归更新成绿色;replay 直接比较已有 golden,失败面更小。 -**新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux consumer job 已消费同一 built-tree artifact,并已被统一的 required verdict 聚合。 +**新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux 消费方 job 已负责该构建,并已被统一的 required verdict 聚合。 **用 jsdom 快照代替真实 Chromium。** 已否决:jsdom 不覆盖浏览器、HTTP/SSE 承载及真实 client plugin bundle 组合;它保留为快速的下层反馈,不能替代 assembled browser chain。 ## 后果 -每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是消费方 job 需要供给 Chromium,并串行运行一轮浏览器场景;built artifact 复用与浏览器缓存避免重跑时重复构建和下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。 +每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是消费方 job 需要供给 Chromium,并串行运行一轮浏览器场景;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ffe59ead9..1f6ec52f0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,12 @@ env: jobs: + # FIXME: Re-enable the three hosted serial reference jobs before cutting a release. + # The self-hosted standby remains active on every master push. + # Three enterprise jobs isolate coverage, static analysis, and the - # build-backed consumer tail. The static job publishes its exact build so - # consumers do not repeat the longest part of their critical path. + # build-backed consumer tail. The consumer job owns the only Linux build so + # all three jobs enter runner allocation independently. # # FAILOVER: each Linux enterprise job resolves its pool through the # DSH_CI_FAILOVER repository variable. Unset (normal), the expressions @@ -47,7 +50,7 @@ jobs: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') - || 'dsh-enterprise-ubuntu-latest-32core-test' }} + || 'dsh-ubuntu-24-04-16core' }} name: node 24 / static env: DSH_GATE_CONCURRENCY: '8' @@ -93,26 +96,13 @@ jobs: DSH_ARCHIVE_BASE_REF: ${{ github.event.pull_request.base.sha }} run: pnpm run check:ci:static - - name: Pack built tree - run: >- - tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz" - apps/*/lib apps/web/dist packages/*/*/lib vendor/*/lib - - - uses: actions/upload-artifact@v7 - with: - name: node-24-built-tree - path: ${{ runner.temp }}/node-24-built-tree.tar.gz - if-no-files-found: error - retention-days: 1 - compression-level: 0 - node-24-coverage: if: github.event_name == 'pull_request' runs-on: >- ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') - || 'dsh-enterprise-ubuntu-24-04-32core-test' }} + || 'dsh-ubuntu-24-04-16core' }} name: node 24 / coverage env: # Failover shrinks the worker bound: the hosted 32-core runner is @@ -120,9 +110,8 @@ jobs: # across six always-on runner instances, and the timing-sensitive # process suites have documented aggregate-contention failures. # 8 × 6 instances = 48 workers worst case on 64 cores. - DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '8' }} + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '6' }} DSH_GATE_CONCURRENCY: '3' - NODE_OPTIONS: '--max-old-space-size=8192' steps: - uses: actions/checkout@v6 with: @@ -172,13 +161,12 @@ jobs: run: pnpm run check:ci:coverage node-24-consumers: - needs: node-24 if: github.event_name == 'pull_request' runs-on: >- ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') - || 'dsh-enterprise-ubuntu-latest-32core-test' }} + || 'dsh-ubuntu-24-04-16core' }} name: node 24 / snapshots and artifacts env: DSH_GATE_CONCURRENCY: '8' @@ -192,14 +180,6 @@ jobs: with: persist-credentials: false - - uses: actions/download-artifact@v8 - with: - name: node-24-built-tree - path: ${{ runner.temp }} - - - name: Restore built tree - run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz" - - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm @@ -225,8 +205,8 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - # Pull requests restore the cache produced by serial-linux on master; - # they do not pay compression and upload on the required path. + # Pull requests restore the cache normally produced by serial-linux on + # master; they do not pay compression and upload on the required path. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: @@ -321,8 +301,9 @@ jobs: # The required pull-request Windows signal: the two blocking win32 surfaces # (workspace build, production site) execute with real, checksum-verified # Windows Node under Wine on standard hosted Linux. The master - # serial-windows job below keeps the complete native-kernel inventory — - # including the observational portability gates this lane does not run — + # serial-windows job below normally keeps the complete native-kernel + # inventory — including the observational portability gates this lane does + # not run — # on real windows-2025. This job only provisions runner state (caches, # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same # script the optional local gate `pnpm run check:windows-wine` runs. @@ -427,12 +408,12 @@ jobs: cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" du -sh "$HOME/wine-debs" - # Master pushes run only the serial reference jobs below. - # Each host executes the complete, unsharded primary Node aggregate with one - # gate worker, giving reviewers a simple cross-platform oracle for completeness - # and timing. + # The hosted reference jobs below are temporarily disabled; the self-hosted + # standby remains active. Each enabled host executes the complete, unsharded + # primary Node aggregate with one gate worker, giving reviewers a simple + # cross-platform oracle for completeness and timing. serial-linux: - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + if: false name: serial / linux runs-on: ubuntu-latest steps: @@ -557,7 +538,7 @@ jobs: run: pnpm run check:ci:linux-primary serial-macos: - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + if: false name: serial / macos runs-on: macos-latest steps: @@ -584,7 +565,7 @@ jobs: run: pnpm run check:ci serial-windows: - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + if: false name: serial / windows runs-on: windows-2025 steps: diff --git a/AGENTS.md b/AGENTS.md index d496ec471a..cce8df3137 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code/Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends + settings/ user-settings seam + file-backed provider acp/ automation-only Agent Client Protocol server ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 67c2b3b551..bc8c3434de 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -102,6 +102,8 @@ - id: tool-fs-search name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false - id: workspace-context name: '@deepseek-ai/dsh-workspace-context' diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index 3d7fe06baf..2f79f136b9 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -111,12 +111,15 @@ - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + # Resolve bind host, SSH launch, and display once at boot, then mount the + # matching dual-face directory picker. Mount -native or -browse directly in + # an overlay to pin the interaction. + - id: directory-picker + name: '@deepseek-ai/dsh-host-directory-picker-auto' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. provider/model are the host default routing — the profile json's # mapping target (user config overrides these engineering defaults). - - id: directory-picker - name: '@deepseek-ai/dsh-host-directory-picker-browse' - - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' config: diff --git a/apps/cli/package.json b/apps/cli/package.json index e3d1976c52..f1db776206 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -58,7 +58,9 @@ "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index aa14308d43..650c089d32 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -19,6 +20,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import // a new recording (workspace-management / sidebar-scrollbar pattern). const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' @@ -85,9 +87,58 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) + it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) + // Exercise the assistant action specifically; package coverage pins the + // user action separately at its own event seq. + await page.getByRole('button', { name: '在新对话中分支' }).last().click() + await expect.poll( + () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), + { timeout: 15_000 }, + ).toBeDefined() + await expect.poll( + () => page.locator('[role="treeitem"]').count(), + { timeout: 10_000 }, + ).toBe(3) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 10_000 }, + ).toBe(1) + // The row action owns a distinct ui-workspace injection from the message + // action above, so exercise both through the loaded app before capture. + const sourceRow = page.locator('[role="treeitem"][aria-selected="true"]') + const rowBox = await sourceRow.boundingBox() + if (rowBox === null) throw new Error('fork source row has no layout box') + const actionButton = sourceRow.locator('button[aria-label^="Session actions for "]') + await sourceRow.hover({ position: { x: rowBox.width - 16, y: rowBox.height / 2 } }) + await expect.poll(() => actionButton.isVisible(), { timeout: 2_000 }).toBe(true) + const buttonBox = await actionButton.boundingBox() + if (buttonBox === null) throw new Error('fork source row action has no layout box') + await page.mouse.click(buttonBox.x + buttonBox.width / 2, buttonBox.y + buttonBox.height / 2) + await page.getByRole('menuitem', { name: 'Fork session' }).click() + await expect.poll( + () => scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession !== undefined).length, + { timeout: 15_000 }, + ).toBe(2) + await expect.poll( + () => page.locator('[role="treeitem"]').count(), + { timeout: 10_000 }, + ).toBe(4) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 10_000 }, + ).toBe(1) + const tree = await captureStableAria( + page, + '[role="tree"][aria-label="Sessions"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(FORK_EXPECTED, tree, MODE) + }) + it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['fork.expected.md', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts new file mode 100644 index 0000000000..28272777fb --- /dev/null +++ b/apps/web/tests/plan-review.e2e.ts @@ -0,0 +1,113 @@ +// Web e2e scenario: the plan-review takeover. The shipped composition mounts +// plan mode and its client seat, so `/plan ` enters plan mode for real +// and the recorded turn ends on exit_plan_mode blocking against the live +// userInteraction seam. The composer is then occupied by the plan decision +// card — not the generic question flow — and approving it through the card +// completes the turn with the approval in the log. +// Replay is deterministic: the plan content arrives from replayed chunks, the +// review wait is real, and the approve click is the test's own gesture (the +// turn cannot complete without it, in record and replay alike). +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plan-review', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +// The waiting golden owns the decision card; the approved golden owns the +// transcript the approval leaves behind — the state the card cannot see. +const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md') +const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md') +const MODE = webSnapshotMode() + +// One command line: /plan enters plan mode and submits the rest as the turn's +// message. The task is deliberately self-contained (nothing to explore in a +// fresh workspace) so the recorded turn is a plan and its review, and the +// approved continuation is one word. +const TASK = 'Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. ' + + 'Call exit_plan_mode with a short plan of at most five bullet points. ' + + 'Once the plan is approved, reply with the single word DONE and stop.' +const LINE = `/plan ${TASK}` + +describe('web e2e: plan review takeover round trip', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + // English page: the decision copy is the surface under test, and the + // golden pins one language. + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('reviews the plan on a decision card and approves through it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([TASK]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000) + await input.fill(LINE) + await input.press('Enter') + + // The card takes over the input area while exit_plan_mode blocks. Its + // presence is a STABLE waiting state (it stays until answered), so a plain + // waitFor is race-free. + const card = page.locator('[data-plan-review-key]') + await card.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) + // The plan-review request must NOT land on the generic question flow. + expect(await page.locator('[data-question-key]').count()).toBe(0) + await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0) + + if (MODE !== 'record') { + const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd) + await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE) + } + + await card.getByRole('button', { name: 'Approve' }).click() + + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + return + } + // World state: the approval reached the tool, and plan mode is left behind. + const results = sessionEvents.filter(e => e.type === 'tool/result') + expect(JSON.stringify(results.at(-1))).toContain('Plan approved') + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // Card gone; regular input restored. + expect(await page.locator('[data-plan-review-key]').count()).toBe(0) + await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 200_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'review.expected.md', 'approved.expected.md']) + }) +}) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 1df3ffc247..1c2ca271aa 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -20,6 +20,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) +const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md') const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() @@ -80,6 +81,16 @@ describe('web e2e: queue row actions', () => { await input.fill(text) await input.press('Enter') } + const queueHeader = page.getByRole('button', { name: '2 条排队消息' }) + await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 }) + .toBe('false') + const collapsedSnapshot = await captureStableAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE) + await queueHeader.click() await expect.poll( () => page.getByRole('button', { name: '删除排队消息' }).count(), { timeout: 10_000 }, @@ -112,6 +123,9 @@ describe('web e2e: queue row actions', () => { }, 120_000) it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['editing.expected.md', 'ui.expected.md']) + await assertFixtureInventory( + SNAPSHOT_DIR, + ['collapsed.expected.md', 'editing.expected.md', 'ui.expected.md'], + ) }) }) diff --git a/apps/web/tests/scaffold-hermetic.e2e.ts b/apps/web/tests/scaffold-hermetic.e2e.ts new file mode 100644 index 0000000000..6e14eebfa5 --- /dev/null +++ b/apps/web/tests/scaffold-hermetic.e2e.ts @@ -0,0 +1,57 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import type {} from '@deepseek-ai/dsh-skill' +import { launchWebScaffold, type WebScaffold } from './scaffold.ts' + +async function writeSkill(root: string, name: string): Promise { + const bundle = join(root, name) + await mkdir(bundle, { recursive: true }) + await writeFile(join(bundle, 'SKILL.md'), `--- +name: ${name} +description: Must not enter the Web replay scaffold +--- + +Ambient host state. +`) +} + +it('isolates replay skill discovery from every ambient host root', async () => { + const ambient = await mkdtemp(join(tmpdir(), 'dsh-web-ambient-skills-')) + const dshHome = join(ambient, 'dsh-home') + const agentsHome = join(ambient, 'agents-home') + const bundled = join(ambient, 'bundled') + await Promise.all([ + writeSkill(join(dshHome, 'skills'), 'ambient-dsh'), + writeSkill(join(agentsHome, 'skills'), 'ambient-agents'), + writeSkill(bundled, 'ambient-bundled'), + ]) + + const originalDshHome = process.env.DSH_HOME + const originalAgentsHome = process.env.DSH_AGENTS_HOME + const originalBundled = process.env.DSH_BUNDLED_SKILL_DIR + process.env.DSH_HOME = dshHome + process.env.DSH_AGENTS_HOME = agentsHome + process.env.DSH_BUNDLED_SKILL_DIR = bundled + let scaffold: WebScaffold | undefined + try { + scaffold = await launchWebScaffold() + const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name) + expect(names).not.toContain('ambient-dsh') + expect(names).not.toContain('ambient-agents') + expect(names).not.toContain('ambient-bundled') + } finally { + try { + await scaffold?.close() + } finally { + if (originalDshHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = originalDshHome + if (originalAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME + else process.env.DSH_AGENTS_HOME = originalAgentsHome + if (originalBundled === undefined) delete process.env.DSH_BUNDLED_SKILL_DIR + else process.env.DSH_BUNDLED_SKILL_DIR = originalBundled + await rm(ambient, { recursive: true, force: true }) + } + } +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1c030b4097..fa4e28433b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -9,12 +9,13 @@ // from live session memory), refresh (keyless replay that rewrites goldens). // // Composition divergences from `dsh web`, all deliberate, all via include -// patches after the shipped surface overlay: temp persistenceRoot; -// workspace-context disabled (recorded fixtures must not embed this repo's -// AGENTS.md); session-title-llm disabled (its fire-and-forget title call -// would race the loop for the session's replay cursor); webserver pinned to -// port 0 with the built dist; keyless modes disable llm-deepseek and fill -// the open llm seam post-boot with installLlmReplay on the settled root ctx +// patches after the shipped surface overlay: temp persistenceRoot; local skill +// roots confined to the temp workspace; workspace-context disabled (recorded +// fixtures must not embed this repo's AGENTS.md); session-title-llm disabled +// (its fire-and-forget title call would race the loop for the session's replay +// cursor); webserver pinned to port 0 with the built dist; keyless modes +// disable llm-deepseek and fill the open llm seam post-boot with +// installLlmReplay on the settled root ctx // (the plugin-row path discards the ReplayHandle; the direct install keeps // assertConsumed for the teardown fixture-consumption check). import { existsSync } from 'node:fs' @@ -172,12 +173,33 @@ export async function launchWebScaffold(options: LaunchOptions = {}): PromiseUser-settings seam"] + pkg_settings_local["settings-local"] pkg_session_telemetry["session-telemetry"] svc_telemetry["ctx.telemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -205,6 +208,8 @@ flowchart LR pkg_session_title --> svc_sessionTitle pkg_session_title_all_messages_llm --> svc_sessionTitle pkg_session_title_first_message_llm --> svc_sessionTitle + pkg_settings --> svc_settings + pkg_settings_local --> svc_settings pkg_skill --> svc_skills pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore @@ -341,6 +346,7 @@ flowchart LR | `ctx.invariants` | `core` | [`invariants`](../packages/support/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) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dde2d6f65b..29b49b7c5d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1238,6 +1238,24 @@ Depends on: [`SessionTitleLlmConfig`](../packages/session-title/session-title-ll Source: [`packages/session-title/session-title-first-message-llm/src/index.ts:15`](../packages/session-title/session-title-first-message-llm/src/index.ts) +## `@deepseek-ai/dsh-settings-local` + +```ts config-catalog +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Settings document path; defaults to `settings.yaml` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} +``` + +Source: [`packages/settings/settings-local/src/index.ts:21`](../packages/settings/settings-local/src/index.ts) + ## `@deepseek-ai/dsh-skill` ```ts config-catalog @@ -1664,8 +1682,10 @@ Source: [`packages/fs/tool-fs/src/index.ts:24`](../packages/fs/tool-fs/src/index Requires: `tools` · `systemPrompt` · `bash` ```ts config-catalog -/** Plugin config (all optional — `Config` supplies the defaults). */ +/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */ export interface Config { + /** Whether an over-cap `glob` page is sampled across top-level entries instead of taking the modification-time head. */ + sampleOverCapGlobResults: boolean /** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */ globMaxResults?: number /** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */ @@ -2252,6 +2272,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) @@ -2282,6 +2303,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) +- `@deepseek-ai/dsh-settings` — abstract `Settings` ([`packages/settings/settings/src/index.ts`](../packages/settings/settings/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4045f7e5be..5e4521bd77 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -659,6 +659,35 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts) +## `settings/*` + +### `settings/updated` — emit + +Committed change to one registered namespace's resolved value. Emitted after the provider persisted (for `update`) or published (`provider`) the change; never emitted when the resolved value is deep-equal. Listener failures are contained and logged — a sync throw and an async rejection alike — except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions. + +```ts cordis-catalog +/** + * Committed change to one registered namespace's resolved value. Emitted + * after the provider persisted (for `update`) or published (`provider`) + * the change; never emitted when the resolved value is deep-equal. + * Listener failures are contained and logged — a sync throw and an async + * rejection alike — except `INVARIANT`-coded failures, which rethrow + * after every listener ran; that rethrow reaches the emitter only from + * synchronous listeners, so invariant checks on this event must not be + * async functions. + * @param ns - the namespace whose resolved value changed. + * @param next - the new resolved value. + * @param prev - the previous resolved value. + * @param source - whether the change entered through `update()` or the provider. + * @mode emit + */ +'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void +``` + +Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md) + +Source: [`packages/settings/settings/src/index.ts:108`](../../packages/settings/settings/src/index.ts) + ## `skills/*` ### `skills/change` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ee713636ac..1f33303269 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -908,7 +908,7 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](../core-data-structures/core.md) -Source: [`packages/plan/plan-mode/src/index.ts:179`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:182`](../../packages/plan/plan-mode/src/index.ts) ## `ctx.pty` — `PtyService` @@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:713`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:714`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1639,6 +1639,62 @@ Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](. Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages/session-title/session-title/src/index.ts) +## `ctx.settings` — `Settings` (abstract seam) + +Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event. + +```ts cordis-catalog +/** + * Register a namespace schema and receive its owner scope. The registration + * is an effect on the calling plugin's fiber: disposing that fiber removes + * the namespace and its observers. An invalid stored section fails the + * registration itself — the earliest point where the schema can judge it. + * @param ns - unique namespace; duplicate registration fails loud. + * @param schema - schemastery schema resolving this namespace's value. + * @param options - composition `base` layer and effect timing. + * @returns the owner scope for reads, observation, and updates. + */ +register(ns: SettingsNamespace, schema: z, options?: SettingsRegisterOptions): SettingsScope + +/** + * Describe every registered namespace for configuration surfaces. + * @returns one descriptor per registered namespace, in registration order. + */ +describe(): SettingsDescriptor[] + +/** + * Read one registered namespace's resolved value. + * @param ns - the namespace to read. + * @returns the resolved value, or `undefined` while unregistered. + */ +get(ns: SettingsNamespace): unknown + +/** + * Merge a patch into one registered namespace's user layer, validate the + * resolved candidate, persist through the provider, then commit and emit. + * A validation failure rejects before anything is persisted. Writes to one + * namespace are serialized: concurrent updates apply in call order, each + * merging over the previous write's committed section. + * @param ns - the registered namespace to update. + * @param patch - plain-object patch over the user section. + */ +async update(ns: SettingsNamespace, patch: object): Promise + +/** + * Replace one registered namespace's user section wholesale, validate, + * persist, then commit and emit. Keys absent from `section` fall back to the + * composition `base` and schema defaults — this is the removal/reset path a + * merge-only patch cannot express (`replace({})` re-inherits everything). + * @param ns - the registered namespace to replace. + * @param section - the complete next user section. + */ +async replace(ns: SettingsNamespace, section: object): Promise +``` + +Types: [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) + +Source: [`packages/settings/settings/src/index.ts:250`](../../packages/settings/settings/src/index.ts) + ## `ctx.skills` — `SkillService` Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand. @@ -2197,7 +2253,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:240`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:241`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` @@ -2284,7 +2340,7 @@ async ask(request: AskUserQuestionRequest): Promise Types: [AskUserQuestionAnswer](../core-data-structures/user-interaction.md) · [AskUserQuestionRequest](../core-data-structures/user-interaction.md) · [UserInteractionProvider](../core-data-structures/user-interaction.md) -Source: [`packages/ui/user-interaction/src/index.ts:50`](../../packages/ui/user-interaction/src/index.ts) +Source: [`packages/ui/user-interaction/src/index.ts:51`](../../packages/ui/user-interaction/src/index.ts) ## `ctx.web` — `WebService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 6321c85127..28a148d7ab 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: dad533cee00646a40f57bd9097b2cceb8e9de9e2 -core.zh.md: 9e8afac0744fcf0df8c35dad5debce746d6614c6 +core.md: a12e96b4156b4ccd8f6f0c453224ed57d6040966 +core.zh.md: ac10c801bde8898ebd3393b05a94cc63627e1b89 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dad533cee0..a12e96b415 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -24,6 +24,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | +| [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 9e8afac074..ac10c801bd 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -24,6 +24,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | +| [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 7c99825d0a..17c4ab68ba 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 769d5db301e3e81664c732ab1685c859a00cceb2 -session.zh.md: 7af459949eda1b939596c20adf1c9e55f6d2b2b4 +session.md: 5389c2e2114094df5afdca25afd904b2cbbf8270 +session.zh.md: 29c4853213dfc886155e3f8d0991fa161e05c71a diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 769d5db301..5389c2e211 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -269,7 +269,7 @@ interface SurfaceIntent { } ``` -Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived history. Non-surface types reject it at compile time. +Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. A human-facing transcript is the other projection and reads the log's append-origin events instead, because the surface deliberately shadows the ranges a replacement summarizes (`isAppendSurfaceEvent` in [dsh-session](../../packages/core/session/README.md)). Non-surface types reject it at compile time. The same provenance distinction applies here: only `assistant/message` may carry a present empty `sourceEventSeqs`; omission does not assert that its source stream was empty. @@ -388,7 +388,8 @@ declare class Session { * the ordered surface; `sourceEventSeqs` records provenance (the seq * numbers of events this one derives from). REQUIRED for * {@link SurfaceEventType} events (every message-producing event must - * declare how it joins the surface, the sole source of derived history) and + * declare how it joins the surface, the sole source of derived model + * history) and * rejected by the compiler for non-surface types like `turn/start` or * `assistant/chunk`. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 7af459949e..29c4853213 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -271,7 +271,7 @@ interface SurfaceIntent { } ``` -对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生历史的唯一来源)。非 surface 类型在编译期拒绝此参数。 +对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生模型历史的唯一来源)。面向人类的记录(transcript)是另一个投影,读取的是日志中追加来源的事件,因为 surface 会有意遮蔽替换所概括的范围(见 [dsh-session](../../packages/core/session/README.md) 的 `isAppendSurfaceEvent`)。非 surface 类型在编译期拒绝此参数。 此处适用相同的溯源区分:只有 `assistant/message` 可以携带存在但为空的 `sourceEventSeqs`;省略该字段并不表示其源流为空。 @@ -390,7 +390,8 @@ declare class Session { * the ordered surface; `sourceEventSeqs` records provenance (the seq * numbers of events this one derives from). REQUIRED for * {@link SurfaceEventType} events (every message-producing event must - * declare how it joins the surface, the sole source of derived history) and + * declare how it joins the surface, the sole source of derived model + * history) and * rejected by the compiler for non-surface types like `turn/start` or * `assistant/chunk`. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of diff --git a/docs/core-data-structures/settings.i18n.yaml b/docs/core-data-structures/settings.i18n.yaml new file mode 100644 index 0000000000..7a971156e7 --- /dev/null +++ b/docs/core-data-structures/settings.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 docs/core-data-structures/settings.md +settings.md: 381b36b3ff2f45a2090a2a2eac0f700bd00270c4 +settings.zh.md: bc6547db3b05c5a78f112462ae205d848f93da60 diff --git a/docs/core-data-structures/settings.md b/docs/core-data-structures/settings.md new file mode 100644 index 0000000000..381b36b3ff --- /dev/null +++ b/docs/core-data-structures/settings.md @@ -0,0 +1,99 @@ +# User Settings + +English | [中文](settings.zh.md) + +The user-settings seam of [dsh-settings](../../packages/settings/settings) holds one user-owned document of per-namespace sections and resolves each registered namespace as schema defaults, then the registrant's composition `base`, then the user section. Providers such as [dsh-settings-local](../../packages/settings/settings-local) store the raw document and push external edits; consumer plugins register a schema and read or observe the resolved value. Composition config stays in `cordis.yml` — a namespace carries only the user-editable subset. + +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) + +## Identity + +A namespace names one plugin-owned section of the user document. The brand keeps namespaces from mixing with other cross-boundary ids; construction validates the lowercase kebab-case shape. + +```ts type-equiv +/** Nominal id of one registered settings namespace. */ +type SettingsNamespace = Branded<'SettingsNamespace'> +``` + +## Registration + +Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer and the owner's effect timing. + +```ts type-equiv +/** Registration options beyond the namespace schema. */ +interface SettingsRegisterOptions { + /** Composition-layer values resolved below the user layer (entry-config subset). */ + base?: Partial + /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ + applies?: SettingsApplies +} +``` + +`applies` is a UI hint, not a mechanism: a `restart` owner simply never watches, so its value is read once at construction and configuration surfaces can badge the pending change. + +```ts type-equiv +/** When a namespace's changes take effect for its owner. */ +type SettingsApplies = 'live' | 'restart' +``` + +## Owner scope + +The scope is the owner-facing handle. `update` merges a sparse patch over the user section only (never into `base`); `replace` sets the section wholesale, which is the removal/reset path — keys absent from the replacement re-inherit `base` and schema defaults. Writes to one namespace are serialized in call order, and resolved values are deep-frozen snapshots. + +```ts type-equiv +/** Owner-facing handle for one registered namespace. */ +interface SettingsScope { + /** Current resolved value: schema defaults, then `base`, then the user layer. */ + get(): T + /** + * Observe committed changes to this namespace's resolved value. Invocations + * of one callback run asynchronously, one at a time, in commit order; a + * rejection is contained and logged like a sync throw. After the disposer + * returns, no further invocation starts — one already queued is skipped; + * one already started still settles, and service disposal waits for it. + * @param callback - invoked after each commit with the next and previous values. + * @returns the disposer removing this observer. + */ + watch(callback: (next: T, prev: T) => void | Promise): () => void + /** + * Merge a partial patch into this namespace's user layer and persist it. + * @param patch - plain-object patch over the user section; JSON-shaped data + * only (non-JSON values reject with their path before anything persists). + */ + update(patch: object): Promise + /** + * Replace this namespace's user section wholesale; absent keys re-inherit + * the composition `base` and schema defaults (`replace({})` resets all). + * @param section - the complete next user section; JSON-shaped data only, + * as for {@link update}. + */ + replace(section: object): Promise +} +``` + +## Descriptors + +`describe()` serializes every registered namespace for configuration surfaces: the schemastery `toJSON()` envelope drives schema-rendered forms, and the resolved value fills them. + +```ts type-equiv +/** One registered namespace as surfaced to configuration UIs. */ +interface SettingsDescriptor { + /** The registered namespace. */ + ns: SettingsNamespace + /** Serialized schemastery schema (`schema.toJSON()`). */ + schema: unknown + /** Current resolved value. */ + value: unknown + /** Owner's declared effect timing. */ + applies: SettingsApplies +} +``` + +## Change commits + +Every committed change — an in-process write or an externally observed provider edit — emits `settings/updated (ns, next, prev, source)` after the new value is authoritative, and never when the resolved value is deep-equal. The source tag separates the two entry paths. + +```ts type-equiv +/** Origin of one committed settings change. */ +type SettingsUpdateSource = 'update' | 'provider' +``` diff --git a/docs/core-data-structures/settings.zh.md b/docs/core-data-structures/settings.zh.md new file mode 100644 index 0000000000..bc6547db3b --- /dev/null +++ b/docs/core-data-structures/settings.zh.md @@ -0,0 +1,99 @@ +# 用户设置 + +[English](settings.md) | 中文 + +[dsh-settings](../../packages/settings/settings) 的用户设置 seam 持有一份按 namespace 分节的用户文档,并把每个已注册 namespace 解析为:schema 默认值,然后注册方的组合 `base`,最后用户分节。[dsh-settings-local](../../packages/settings/settings-local) 这类 provider 存储原始文档并推送外部编辑;消费插件注册 schema 后读取或观察解析值。组合配置仍留在 `cordis.yml`——namespace 只承载用户可编辑子集。 + +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) + +## 标识 + +namespace 命名用户文档中一个插件所有的分节。brand 使其不与其他跨边界 id 混用;构造时校验小写 kebab-case 形态。 + +```ts type-equiv +/** Nominal id of one registered settings namespace. */ +type SettingsNamespace = Branded<'SettingsNamespace'> +``` + +## 注册 + +注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose 该 fiber 即移除 namespace 及其观察者。options 携带组合层与 owner 的生效时机。 + +```ts type-equiv +/** Registration options beyond the namespace schema. */ +interface SettingsRegisterOptions { + /** Composition-layer values resolved below the user layer (entry-config subset). */ + base?: Partial + /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ + applies?: SettingsApplies +} +``` + +`applies` 是 UI 提示而非机制:`restart` 的 owner 只是从不 watch,其值在构造期读取一次,配置界面可为待生效变更加标。 + +```ts type-equiv +/** When a namespace's changes take effect for its owner. */ +type SettingsApplies = 'live' | 'restart' +``` + +## Owner scope + +scope 是面向 owner 的句柄。`update` 把稀疏 patch 只合并进用户分节(绝不进 `base`);`replace` 整体替换分节,是删除/重置路径——替换中缺席的键重新继承 `base` 与 schema 默认值。同一 namespace 的写入按调用顺序串行,解析值是深冻结快照。 + +```ts type-equiv +/** Owner-facing handle for one registered namespace. */ +interface SettingsScope { + /** Current resolved value: schema defaults, then `base`, then the user layer. */ + get(): T + /** + * Observe committed changes to this namespace's resolved value. Invocations + * of one callback run asynchronously, one at a time, in commit order; a + * rejection is contained and logged like a sync throw. After the disposer + * returns, no further invocation starts — one already queued is skipped; + * one already started still settles, and service disposal waits for it. + * @param callback - invoked after each commit with the next and previous values. + * @returns the disposer removing this observer. + */ + watch(callback: (next: T, prev: T) => void | Promise): () => void + /** + * Merge a partial patch into this namespace's user layer and persist it. + * @param patch - plain-object patch over the user section; JSON-shaped data + * only (non-JSON values reject with their path before anything persists). + */ + update(patch: object): Promise + /** + * Replace this namespace's user section wholesale; absent keys re-inherit + * the composition `base` and schema defaults (`replace({})` resets all). + * @param section - the complete next user section; JSON-shaped data only, + * as for {@link update}. + */ + replace(section: object): Promise +} +``` + +## 描述符 + +`describe()` 为配置界面序列化每个已注册 namespace:schemastery 的 `toJSON()` 信封驱动 schema 渲染的表单,解析值填充表单。 + +```ts type-equiv +/** One registered namespace as surfaced to configuration UIs. */ +interface SettingsDescriptor { + /** The registered namespace. */ + ns: SettingsNamespace + /** Serialized schemastery schema (`schema.toJSON()`). */ + schema: unknown + /** Current resolved value. */ + value: unknown + /** Owner's declared effect timing. */ + applies: SettingsApplies +} +``` + +## 变更提交 + +每次提交的变更——进程内写入或 provider 观察到的外部编辑——在新值成为权威值之后发出 `settings/updated (ns, next, prev, source)`,解析值深相等时绝不发出。source 标记区分两条入口路径。 + +```ts type-equiv +/** Origin of one committed settings change. */ +type SettingsUpdateSource = 'update' | 'provider' +``` diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index 66cb12815e..bc281d9bb7 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -user-interaction.md: 798a9790f424683775284a98421be08e6e1399e3 -user-interaction.zh.md: 12bfcffe4fe4caaacb54e90126eac55e333d64a5 +# pnpm run verify-translation-pairing --write docs/core-data-structures/user-interaction.md +user-interaction.md: 2483382eee07d379b13d456149096c8afab70e4b +user-interaction.zh.md: 57f57277975597fff712705cc741ae8e78643642 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 798a9790f4..2483382eee 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -20,6 +20,30 @@ interface AskUserQuestionOption { } ``` +## Presentation intent + +`AskUserQuestionIntent` is the optional declaration that a question IS a decision of a known shape. It is tagged on `kind` so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent shapes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads one answer shape either way. `approve` names the affirmative option instead of relying on option order. `ask()` rejects the two assertions no type can carry: an `approve` naming none of its own question's options, and an intent on a question with no `detail`. + +```ts type-equiv +/** + * A caller-declared presentation intent: the question IS a decision of this + * shape, so a UI that recognises the tag may present it as such instead of as a + * generic option list. Tagged so further intents can be added; a UI that does + * not know a tag renders the generic flow, and the answer encoding is identical + * either way — an intent shapes presentation only, never the protocol. + */ +type AskUserQuestionIntent = { + /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ + kind: 'plan-review' + /** + * The option label that approves the plan; every other option declines it. + * Named rather than positional so no UI infers the verdict from option order. + * An `approve` naming no option of its own question is rejected at `ask()`. + */ + approve: string +} +``` + ## Question item `AskUserQuestionItem` is one question in a request. The caller supplies a stable `id`, which is echoed back with the answer so batched questions remain routable. Optional `detail` carries supporting text that providers render with the question but keep out of selectable option labels. @@ -39,6 +63,8 @@ interface AskUserQuestionItem { options?: AskUserQuestionOption[] /** Whether more than one option may be selected. Defaults to single-select. */ multiSelect?: boolean + /** Optional presentation intent for capable UIs; absent asks for the generic option list. */ + intent?: AskUserQuestionIntent } ``` diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 12bfcffe4f..57f5727797 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -20,6 +20,30 @@ interface AskUserQuestionOption { } ``` +## 呈现意图 + +`AskUserQuestionIntent` 是一项可选声明:某个问题本身就是一次已知形状的决定。它按 `kind` 打标签,因此意图可以扩充;不认识某个标签的 UI 渲染通用选项列表。意图只塑造呈现 —— 遵循它的 UI 回答的仍是通用 UI 会发送的那些 option label,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名肯定选项,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上。 + +```ts type-equiv +/** + * A caller-declared presentation intent: the question IS a decision of this + * shape, so a UI that recognises the tag may present it as such instead of as a + * generic option list. Tagged so further intents can be added; a UI that does + * not know a tag renders the generic flow, and the answer encoding is identical + * either way — an intent shapes presentation only, never the protocol. + */ +type AskUserQuestionIntent = { + /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ + kind: 'plan-review' + /** + * The option label that approves the plan; every other option declines it. + * Named rather than positional so no UI infers the verdict from option order. + * An `approve` naming no option of its own question is rejected at `ask()`. + */ + approve: string +} +``` + ## 问题条目 `AskUserQuestionItem` 是请求中的一个问题。调用方提供稳定的 `id`,它会随答案原样返回,使批量问题仍可路由。可选的 `detail` 携带辅助文本;提供方会将其随问题渲染,但不会放入可选 option label。 @@ -39,6 +63,8 @@ interface AskUserQuestionItem { options?: AskUserQuestionOption[] /** Whether more than one option may be selected. Defaults to single-select. */ multiSelect?: boolean + /** Optional presentation intent for capable UIs; absent asks for the generic option list. */ + intent?: AskUserQuestionIntent } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b2a87a76e8..c82ffb5883 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,6 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:108`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 27c185cb40..ea5e1f4334 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -189,6 +189,7 @@ flowchart TD subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] + pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] pkg_host_directory_picker_native["host-directory-picker-native"] pkg_host_webserver["host-webserver"] @@ -223,6 +224,10 @@ flowchart TD pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] end + subgraph group_settings["packages/settings"] + pkg_settings["settings"] + pkg_settings_local["settings-local"] + end subgraph group_storage["packages/storage"] pkg_storage["storage"] pkg_storage_domain["storage-domain"] @@ -316,6 +321,8 @@ flowchart TD pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_settings --> pkg_brand + pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants @@ -379,6 +386,9 @@ flowchart TD pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm + pkg_settings_local --> pkg_invariants + pkg_settings_local --> pkg_paths + pkg_settings_local --> pkg_settings pkg_token_meter --> pkg_invariants pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session @@ -446,6 +456,10 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -871,6 +885,7 @@ flowchart TD pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands + pkg_tui --> pkg_compact pkg_tui --> pkg_goal pkg_tui --> pkg_invariants pkg_tui --> pkg_llm @@ -1020,6 +1035,7 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1039,6 +1055,7 @@ flowchart TD | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`settings-local`](../packages/settings/settings-local) | `settings` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | @@ -1058,6 +1075,7 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | @@ -1137,7 +1155,7 @@ flowchart TD | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 176558220d..7d6fa79dea 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -23,7 +23,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | -| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | +| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | @@ -472,7 +472,7 @@ The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an ` ### `glob` -Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, including hidden and ignored files (VCS metadata directories are excluded). Returns the first 100 paths inline; a capped result reports where the complete list was saved. +Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result instead returns 100 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries. ```json { @@ -480,7 +480,7 @@ Find files whose paths match a glob pattern. Returns matching paths sorted by mo "properties": { "pattern": { "type": "string", - "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\")." + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." }, "path": { "type": "string", @@ -524,7 +524,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts) -glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. +glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. ## `@deepseek-ai/dsh-tool-pty` diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 2be63076e8..b3508c3ae5 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -43,6 +43,8 @@ const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.ur const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) +const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) +const FS_SEARCH_BIN = fileURLToPath(new URL('./fixtures/fs-search-bin', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -147,6 +149,19 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: true, }, + // The real Loader/app/bash path executes a deterministic rg stand-in at the + // external-process seam, pinning over-cap glob sampling without depending on + // a host-installed ripgrep binary. + { + name: 'fs-glob-sampling', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'fs-search', + configPath: FS_SEARCH_CONFIG, + env: { PATH: `${FS_SEARCH_BIN}:${process.env.PATH ?? ''}` }, + posixOnly: true, + }, { name: 'fs-read', hasModelTurn: true, recorded: true }, { name: 'fs-write', hasModelTurn: true, recorded: true }, { name: 'fs-edit', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fixtures/fs-search-bin/rg b/examples/acp-agent/tests/fixtures/fs-search-bin/rg new file mode 100755 index 0000000000..181ad68837 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/fs-search-bin/rg @@ -0,0 +1,10 @@ +#!/bin/sh +printf '%s\n' \ + 'archive/a.ts' \ + 'archive/b.ts' \ + 'archive/c.ts' \ + 'old\one' \ + 'old\two' \ + 'src/index.ts' \ + 'docs/guide.md' \ + 'test/spec.ts' diff --git a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml new file mode 100644 index 0000000000..141691a087 --- /dev/null +++ b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml @@ -0,0 +1,35 @@ +# Minimal keyless composition: real app, bash, and search tool; replayed model. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + toolTasks: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: true + globMaxResults: 4 diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml new file mode 100644 index 0000000000..153128f914 --- /dev/null +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -0,0 +1,33 @@ +# Minimal live counterpart for the glob-sampling snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: false + skills: + enabled: false + toolTasks: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: true + globMaxResults: 4 diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json b/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json new file mode 100644 index 0000000000..cc5fc95e59 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl new file mode 100644 index 0000000000..8579543459 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -0,0 +1,24 @@ +{"type":"session","version":0,"id":"f5a99d52-3eaa-4ce7-858d-61d4fd77df2a","createdAt":1785218400000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785218400001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"4b397774-ea1a-445a-8116-d25647bf32e6"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785218400003,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785218400004,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785218400005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785218400006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":1785218400007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":1785218400011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"d8c174b5-2f08-49b3-80d5-a69aabefbd7a"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1785218400012,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}} +{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":1785218400014,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":1785218400015,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":1785218400016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":16,"time":1785218400017,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GLOB_SAMPLED"}}} +{"type":"assistant/chunk","seq":17,"time":1785218400018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} +{"type":"assistant/chunk","seq":18,"time":1785218400019,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":19,"time":1785218400020,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":20,"time":1785218400021,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"6f8b4c32-baad-405b-9957-435dc5c855b2"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":1785218400022,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":22,"time":1785218400023,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl new file mode 100644 index 0000000000..691b11cef0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GLOB_SAMPLED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md new file mode 100644 index 0000000000..eb22966cdb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md @@ -0,0 +1,9 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a concise snapshot agent working in {{cwd}}. + +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree. + +Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json new file mode 100644 index 0000000000..4cb292798a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json @@ -0,0 +1,82 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "glob", + "description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 4 paths come back in modification-time order; a larger result instead returns 4 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "grep", + "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" + ] + } + } + ], + "changes": [] +} diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 458da3b713..fb1b330d86 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 3f467641bbc9eae14a94aa2d3bff0402116a9d3f -README.zh.md: c9d11bbf6239b4239a4e037dac63b05d3a9a58f7 +README.md: 11179cf6676d1b4382816e34285529b51152fe8d +README.zh.md: 100d918287613973604b2f85060572b8ee41d132 diff --git a/packages/README.md b/packages/README.md index 3f467641bb..11179cf667 100644 --- a/packages/README.md +++ b/packages/README.md @@ -39,6 +39,7 @@ Packages live at `packages///`; groups are containers, while names r | [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface | +| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface | | [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index c9d11bbf62..100d918287 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -39,6 +39,7 @@ | [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 | +| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 | | [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cb79a6b9a2..916125ff6f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1042,6 +1042,56 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const appended = logOf(sessionId).at(-1) as SessionEvent return ok(request, { title: normalized, seq: appended.seq }) }, + fork: (request) => { + const { sessionId, atSeq } = request.payload + const source = summaryOf(sessionId) + if (source === undefined) { + return err(request, { + code: 'session-not-found', + message: `no session ${sessionId}`, + details: { sessionId }, + }) + } + const log = logs.get(sessionId) ?? [] + const lastSeq = log.at(-1)?.seq ?? -1 + const anchoredBoundary = atSeq === undefined + ? undefined + : log.find(e => e.type === 'turn/end' && e.seq >= atSeq) + const boundary = anchoredBoundary + ?? (atSeq === undefined || atSeq > lastSeq + ? log.findLast(e => e.type === 'turn/end') + : undefined) + if (boundary === undefined) { + return err(request, { + code: 'fork-unavailable', + message: atSeq !== undefined && atSeq <= lastSeq + ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` + : `session ${sessionId} has no completed turn`, + details: { sessionId }, + }) + } + let cut = boundary.seq + 1 + while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ + const child: SessionSummary = { + sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, + parentSessionId: sessionId, + ...source.cwd === undefined ? {} : { cwd: source.cwd }, + } + logs.set(child.sessionId, log.slice(0, cut)) + sessions.push(child) + emitHost({ + type: 'host/session-added', sessionId: child.sessionId, blank: false, + parentSessionId: sessionId, + ...source.cwd === undefined ? {} : { cwd: source.cwd }, + }) + const workspace = workspaces.find(w => w.sessionIds.includes(sessionId)) + if (workspace !== undefined) { + workspace.sessionIds = [child.sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + return ok(request, { sessionId: child.sessionId }) + }, history: async (request) => { const log = logs.get(request.payload.sessionId) ?? [] // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency). @@ -1591,6 +1641,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.models': return this.api.sessions.models(request) case 'session.selectModel': return this.api.sessions.selectModel(request) case 'session.rename': return this.api.sessions.rename(request) + case 'session.fork': return this.api.sessions.fork(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.updateQueue': return this.api.sessions.updateQueue(request) case 'session.cancel': return this.api.sessions.cancel(request) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 89bcb9301d..8e99d9f9bb 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -46,6 +46,7 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) + onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = () => Promise.resolve(ok({ @@ -99,6 +100,7 @@ export class FakeApiClient implements IApiClient { selectModel: (payload: ModelTarget & { sessionId: SessionId }) => this.record('session.selectModel', payload, this.onSelectModel(payload)), rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), + fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index fa4c87adb2..e82da5cc62 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140 -README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692 +README.md: b85deeec92fd4da1f342b5536757692f594853a5 +README.zh.md: 2dbb66c56ad5687fb299fe030d0abfe451004a62 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 766d851622..b85deeec92 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -28,6 +28,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op. +## Session forking + +`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child. + ## Session model selection Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9b514afca9..2dbb66c56a 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -28,6 +28,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。 +## 会话 fork + +`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 + ## 会话模型选择 每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。 diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index d26b392072..280bc602eb 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -29,6 +29,17 @@ export interface ISessions { open(id: SessionId): void /** Clear the current selection into the no-session view state. */ clear(): void + /** + * Fork a session from a completed-turn prefix of the source; on resolution + * the child is in the list store and `open()` can target it. + * @param opts - source session id, the optional event seq anchoring the + * cut (the boundary is the first turn/end at or after it; an in-log + * anchor in an open turn is unavailable rather than clipped backward), + * and whether to increment an inherited durable title before resolving. + * @returns the child session id. + * @throws when the fork fails, or when a requested child-title rename fails after creation. + */ + fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise /** * Register a per-session standard-props provider (hooks become `use` * selector hooks on the render side; props spread verbatim). diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index ff68e21921..4ba134b321 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -289,6 +289,40 @@ export class SessionManager { } } + /** + * Contract session.fork; on success merge the child into summaries + * immediately (same synchronous-addressability guarantee as create). The + * child carries the source's history, so it is never blank; lineage rides + * parentSessionId so the list nests it under its source. A child published + * before Workspace attachment fails is also reconciled into the list. + * @param opts - source session and the optional seq anchoring the cut. + * @returns the fork result (the child session id). + */ + async fork( + opts: { sessionId: SessionId; atSeq?: number }, + ): Promise> { + try { + const source = this.summaries.find(s => s.sessionId === opts.sessionId) + const { result } = await this.api.sessions.fork({ + sessionId: opts.sessionId, + ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, + }) + const childId = result.ok + ? result.value.sessionId + : workspaceAttachSessionId(result.error) + if (childId !== undefined) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: childId, updatedAt: Date.now(), running: false, blank: false, + parentSessionId: opts.sessionId, + ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), + } }) + } + return result + } catch (error) { + return transportError(error) + } + } + /** * Insert-or-enrich a locally synthesized summary: a new id prepends; an * existing entry only gains fields it lacks (the session-added frame and the diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 71067d6330..342fc3b62e 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -81,6 +81,22 @@ export class SessionCreateError extends Error { } } +/** Structured session-fork failure. */ +export class SessionForkError extends Error { + override readonly name = 'SessionForkError' + + /** + * @param rpcError - Host business or folded transport error. + * @param sourceSessionId - the session the fork was cut from. + */ + constructor( + readonly rpcError: RpcError, + readonly sourceSessionId: SessionId, + ) { + super(`session fork failed: ${rpcError.code}: ${rpcError.message}`) + } +} + /** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ export interface SessionBinding { readonly sessionId: SessionId @@ -121,6 +137,24 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id: return id } +/** + * Increment a trailing fork number while preserving its half-width or + * full-width parentheses; an unnumbered title starts with ` (1)`. + * @param title - source session's durable title. + * @returns the title assigned to the fork child. + */ +function increasedForkTitle(title: string): string { + const ascii = /^(.*?)\((\d+)\)$/u.exec(title) + if (ascii?.[1] !== undefined && ascii[2] !== undefined) { + return `${ascii[1]}(${BigInt(ascii[2]) + 1n})` + } + const fullWidth = /^(.*?)((\d+))$/u.exec(title) + if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) { + return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})` + } + return `${title} (1)` +} + interface ScopeRecord { fiber: Fiber ctx: Context @@ -317,6 +351,42 @@ export class SessionsService implements ISessions { return result.value.sessionId } + /** + * Fork a session from a completed-turn prefix of the source (same + * synchronous-addressability guarantee as {@link SessionsService.create}: + * on resolution the child is in the list store and open() can target it). + * @param opts - source session id, the optional event seq anchoring the + * cut (the boundary is the first turn/end at or after it; an in-log + * anchor in an open turn is unavailable rather than clipped backward), + * and whether to increment an inherited durable title before resolving. + * @returns the child session id. + * @throws {SessionForkError} with the source id. + * @throws {Error} when a requested child-title rename fails after creation. + */ + async fork(opts: { + sessionId: SessionId + atSeq?: number + increaseTitle?: boolean + }): Promise { + const sourceTitle = opts.increaseTitle + ? this.list.getSnapshot().byId[opts.sessionId]?.title + : undefined + const result = await this.manager.fork({ + sessionId: opts.sessionId, + ...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }), + }) + if (!result.ok) throw new SessionForkError(result.error, opts.sessionId) + this.projectList() + const childId = result.value.sessionId + if (sourceTitle !== undefined) { + const child = this.binding(childId)?.session + if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`) + const renamed = await child.rename(increasedForkTitle(sourceTitle)) + if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`) + } + return childId + } + /** * Resolve an Agent-scoped context view (use-and-discard). * @param id - session id (the agent identity — 1:1 same axis). diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index eb2a06294e..85dd4c5a55 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -64,6 +64,7 @@ export class FakeApiClient implements IApiClient { onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' } onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) + onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) @@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient { selectModel: (payload: { provider: string; model: string }) => this.record('session.selectModel', payload, this.onSelectModel(payload)), rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), + fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 33b46538d9..afe6308249 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -277,6 +277,23 @@ describe('remaining branches', () => { expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') }) + it('reconciles a fork child published before workspace attachment fails', async () => { + const api = new FakeApiClient() + api.onFork = () => Promise.resolve(err({ + code: 'workspace-attach-failed', + message: 'forked but unattached', + details: { sessionId: S2, workspaceId: 'w1' }, + } as never)) + const manager = new SessionManager(api) + const result = await manager.fork({ sessionId: S1 }) + expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ + sessionId: S2, + parentSessionId: S1, + blank: false, + })]) + }) + it('reconciles a preallocated id after an ordinary transport failure', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 09076bace7..8687f208f0 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -10,7 +10,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' -import { FakeApiClient, deferred, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (s: string): SessionId => s as SessionId @@ -399,6 +399,69 @@ describe('create', () => { }) }) +describe('fork', () => { + it.each([ + ['Roadmap', 'Roadmap (1)'], + ['Roadmap (1)', 'Roadmap (2)'], + ['计划(1)', '计划(2)'], + ['计划 (9)', '计划 (10)'], + ])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => { + const b = bench() + b.svc.handleMuxEnvelope({ + rpcId: 'source-title' as never, + payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never, + }) + await feedList(b, [{ id: 'source', cwd: '/work' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + b.api.onRename = (payload) => { + const { title } = payload as { title: string } + return Promise.resolve(ok({ title, seq: 3 })) + } + + await expect(b.svc.fork({ + sessionId: sid('source'), atSeq: 7, increaseTitle: true, + })).resolves.toBe('child') + + expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }]) + expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }]) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({ + title: childTitle, + displayTitle: childTitle, + parentId: 'source', + }) + }) + + it('does not rename without the title policy or a durable source title', async () => { + const b = bench() + await feedList(b, [{ id: 'source', cwd: '/work' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child') + expect(b.api.callsOf('session.rename')).toEqual([]) + + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') })) + await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2') + expect(b.api.callsOf('session.rename')).toEqual([]) + }) + + it('rejects when child rename fails while keeping the published child addressable', async () => { + const b = bench() + b.svc.handleMuxEnvelope({ + rpcId: 'source-title' as never, + payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never, + }) + await feedList(b, [{ id: 'source' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + b.api.onRename = () => Promise.resolve(err({ + code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') }, + })) + + await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })) + .rejects.toThrow('fork child rename failed: title-invalid: rejected') + expect(b.svc.binding(sid('child'))).toBeDefined() + }) +}) + describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => { it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => { const b = bench() diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 4fdfb32cc0..f40f9a14a5 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -169,7 +169,7 @@ export class TestSessions implements ISessions { private readonly channel: SessionProvideChannel /** Calls observed on the service-level face (open/clear), newest last. */ - readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = [] + readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = [] /** * @param stabilize - the owning runtime's act wrapper. @@ -392,6 +392,17 @@ export class TestSessions implements ISessions { this.list.update((draft) => { draft.current = undefined }) } + /** + * Recorded fork stub: no child materializes (benches asserting the full + * fork flow drive the production service; this face only proves the call). + * @param opts - source session id, optional cut anchor, and client title policy. + * @returns the source id (no child record is created). + */ + fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise { + this.calls.push({ method: 'fork', args: [opts] }) + return Promise.resolve(opts.sessionId) + } + /** * The session face of a fixture (typed view for assertions; fixture * behavior methods are grafted onto it). diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index c5084112eb..62bab2845a 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -201,7 +201,7 @@ describe('sessions', () => { await runtime.dispose() }) - it('records service-face calls; open() moves the selection and clear() empties it', async () => { + it('records service-face calls; open() moves selection, clear() empties it, and fork() echoes the source', async () => { const runtime = await runtimeWithFrame() await runtime.sessions.add({ id: 's1' }) await runtime.sessions.add({ id: 's2' }) @@ -211,9 +211,13 @@ describe('sessions', () => { runtime.sessions.clear() await runtime.flush() expect(runtime.sessions.list.getSnapshot().current).toBeUndefined() + await expect(runtime.sessions.fork({ + sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true, + })).resolves.toBe('s1') expect(runtime.sessions.calls).toEqual([ { method: 'open', args: ['s1'] }, { method: 'clear', args: [] }, + { method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] }, ]) await runtime.dispose() }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ea2de9fd3d..d2a6aee8ea 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 8c1a75cd6d6bf8409eda32a75b342a8a84c94706 -README.zh.md: 42fa2df9f43f95ad32a593f79ff303c87cb55a5c +README.md: 8711d41e966b521f0cca1e01002299355be59d68 +README.zh.md: bed2dba38a74a1b57cf948932b79c8b3796bdb7e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 8c1a75cd6d..8711d41e96 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -20,6 +20,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions. + Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. @@ -38,7 +40,7 @@ None; this package neither assembles nor sends a provider request. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub. +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 42fa2df9f4..bed2dba38a 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -20,6 +20,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。 + 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 @@ -36,9 +38,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 ## 已知限制与暂缓事项 -- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 +- **统计行的耗时只覆盖窗口内消息流**:LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 0262e82987..4067ef512d 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -284,6 +284,13 @@ export function apply(ctx: Context): void { }) }, loadOlder: () => { void scoped.loadOlder() }, + forkAt: (seq) => { + sessions.fork({ sessionId, atSeq: seq, increaseTitle: true }) + .then((childId) => { sessions.open(childId) }) + .catch(() => { + // Fork or child-rename failure keeps the source view untouched. + }) + }, } }, }, ChatView) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 904aeee0d8..7de69083ac 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -23,6 +23,10 @@ export interface AssistantMarkdownProps { interrupted?: boolean | undefined /** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */ time?: number | undefined + /** Event sequence used as the fork boundary; omitted while streaming. */ + seq?: number | undefined + /** Fork the session through the turn containing this finalized message. */ + onFork?: ((seq: number) => void) | undefined } function firstLine(text: string): string { @@ -60,7 +64,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, + blocks, streaming, interrupted, time, seq, onFork, }: AssistantMarkdownProps) { const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so @@ -91,6 +95,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ text={copyText(blocks)} time={time} clock="end" + onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }} className={css.actions} /> )} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b9e1e3351f..2941348eb1 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -230,7 +230,7 @@ function StreamingTail({ useSession, onGrow }: { * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) { +export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -377,6 +377,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio streaming={false} interrupted={node.interrupted} time={node.time} + seq={node.seq} + onFork={forkAt} /> ) } @@ -385,7 +387,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null - return + return } return ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index fc76cfb753..3075c258a6 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,5 +1,6 @@ -// Shared IconActions chrome for user and assistant messages: copy / branch -// live (branch still a stub), date-aware clock, optional edit stub. +// Shared IconActions chrome for user and assistant messages: copy live, +// branch wired through onBranch, date-aware clock, +// optional edit stub. import { useCallback } from 'react' import { @@ -18,17 +19,19 @@ export interface MessageIconActionsProps { clock: 'start' | 'end' /** When true, append the stub edit control (user bubble). */ edit?: boolean | undefined + /** Fork the session at this message. */ + onBranch?: (() => void) | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined } /** * Copy / branch (/ clock) IconActions row shared by user and assistant chrome. - * @param props - Copy text, event time, clock side, optional edit, className. + * @param props - Copy text, event time, clock side, optional edit, branch callback, className. * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, edit, className, + text, time, clock, edit, onBranch, className, }: MessageIconActionsProps) { const day = useCalendarDay() const onCopy = useCallback(() => { @@ -48,7 +51,7 @@ export function MessageIconActions({ - diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index aa56d35270..2eb8e313ae 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -16,6 +16,8 @@ import css from './MessageItem.module.css' export interface MessageItemProps { node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode + /** Fork the session through the turn containing this message (user-bubble branch action). */ + onFork?: (seq: number) => void } function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { @@ -61,7 +63,7 @@ function projectUserText(text: string): ReactNode { return <>{parts} } -export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { +export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) { switch (node.kind) { case 'user': { const { text, rest } = contentText(node.content) @@ -76,6 +78,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) time={node.time} clock="start" edit + onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }} className={css.actions} /> diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 883dfd9723..2c7e806588 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -421,6 +421,8 @@ export interface ChatViewInjected { */ openFile: (path: string) => void loadOlder: () => void + /** Fork the session through the turn containing the message at `seq`, then open the child. */ + forkAt: (seq: number) => void } /** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */ diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 51d0737ee7..46cc018179 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -20,6 +20,8 @@ padding-top: 2px; border-radius: 14px 14px 0 0; background: var(--dsw-specific-tip); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } .panel::after { @@ -32,7 +34,52 @@ pointer-events: none; } +.header { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 36px; + padding: 4px 16px 4px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + text-align: left; + cursor: pointer; +} + +.header:focus-visible { + outline: 2px solid var(--dsw-alias-label-tertiary); + outline-offset: -2px; +} + +.header:disabled { + cursor: default; +} + +.count { + flex: 1 1 auto; + min-width: 0; + font-family: Inter, var(--dsw-font-family); + font-size: 14px; + font-weight: 500; + line-height: 24px; +} + +.chevron { + display: grid; + flex: none; + place-items: center; + width: 14px; + height: 14px; + color: var(--dsw-alias-label-tertiary); +} + .list { + max-height: 180px; + overflow-y: auto; margin: 0; padding: 0; list-style: none; diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 88300f6086..67de5d0796 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -4,11 +4,12 @@ // The 'conversation.input.dock' SlotMap declaration lives in // ../contract/slots.ts beside the other input-region slots. import type { Context } from 'cordis' -import { useEffect, useState } from 'react' +import { useEffect, useId, useState } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { - IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16, + IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, + IconCloseOutline16, IconEditOutline16, IconTrashOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId } from '../contract/queue.ts' import css from './QueueDock.module.css' @@ -22,18 +23,28 @@ export interface QueueDockInjected { /** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected -/** Queue strip: one preview line per queued message; renders null when the queue is empty. */ +/** + * Queue strip: one item renders directly; multiple items default to a + * collapsible count header; an empty queue renders nothing. + */ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { const queue = useSession(s => s.queue) const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) + const [collapsed, setCollapsed] = useState(true) + const listId = useId() useEffect(() => { + if (queue.length === 0 && !collapsed) setCollapsed(true) if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null) - }, [editing, queue]) + }, [collapsed, editing, queue]) if (queue.length === 0) return null + const interactionActive = editing !== null || busy !== null + const expanded = !collapsed || interactionActive + const listVisible = queue.length === 1 || expanded + const applyAction = async ( itemId: QueueItemId, action: QueueAction, @@ -63,8 +74,23 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { return (
-
    - {queue.map(row => ( + {queue.length > 1 && ( + + )} +
diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 6fa9bb999f..ce9f03fb0b 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -111,6 +111,12 @@ function rowButton(item: HTMLElement): HTMLButtonElement { } describe('DirectoryBrowser', () => { + it('renders nothing and launches no listing while initially closed', () => { + const b = mount({ open: false }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(b.listDirectory).not.toHaveBeenCalled() + }) + it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -235,39 +241,249 @@ describe('DirectoryBrowser', () => { expect(columns()).toHaveLength(1) }) - it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { - const signals: (AbortSignal | undefined)[] = [] - const settlers: ((value: DirectoryListing) => void)[] = [] - // Only the FIRST explicit HOME request (the parent leg) hangs; the later - // home crumb jump lists normally. - let homeCalls = 0 - const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => { - signals.push(signal) - if (path === HOME && ++homeCalls === 1) { - return new Promise((resolve) => { settlers.push(resolve) }) - } - return listingFor(path) + it('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { + vi.useFakeTimers() + try { + const signals: (AbortSignal | undefined)[] = [] + const settlers: ((value: DirectoryListing) => void)[] = [] + // Only the FIRST explicit HOME request (the parent leg) hangs; the + // later home crumb jump lists normally. + let homeCalls = 0 + const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => { + signals.push(signal) + if (path === HOME && ++homeCalls === 1) { + return new Promise((resolve) => { settlers.push(resolve) }) + } + return Promise.resolve(listingFor(path)) + }) + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target settled but the parent leg hangs: inside the wait bound + // nothing commits yet. + await act(async () => {}) + expect(settlers).toHaveLength(1) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + // The wait bound expires: the target commits alone — editor closed, + // single-pane DOCS level. + await act(async () => { vi.advanceTimersByTime(200) }) + expect(screen.getByRole('listitem').textContent).toBe('harness') + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(columns()).toHaveLength(1) + // A newer jump aborts the pending parent leg ON THE WIRE, not merely + // dropping its settlement. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + expect(signals[2]?.aborted).toBe(true) + await act(async () => {}) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Its late resolution changes nothing either. + await act(async () => { settlers[0]!(listingFor(HOME)) }) + expect(columns()).toHaveLength(1) + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + /** + * Listing fake whose explicit-path scans stay pending until the test + * settles them by path; the absent-path form (the initial home listing) + * resolves normally so mounting is a one-flush setup. + */ + function manualLister() { + const settlers = new Map void>() + const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => { + if (path === undefined) return Promise.resolve(listingFor(path)) + return new Promise((resolve) => { settlers.set(path, resolve) }) }) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) - fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) - fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) - // The target leg commits at once: editor closed, single-pane DOCS level, - // while the parent leg (upgrade) is still in flight. - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) - expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() - expect(columns()).toHaveLength(1) - await waitFor(() => { expect(settlers).toHaveLength(1) }) - // A newer jump aborts the pending parent leg ON THE WIRE, not merely - // dropping its settlement. - fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) - expect(signals[2]?.aborted).toBe(true) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) - // Its late resolution changes nothing either. - await act(async () => { settlers[0]!(listingFor(HOME)) }) - expect(columns()).toHaveLength(1) - expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + return { settlers, listDirectory } + } + + it('lands a navigation as ONE two-pane frame: the stale view holds until both legs arrive', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target settles while the parent leg is still in flight: nothing + // commits yet — the editor stays open over the stale home level, and no + // single-pane DOCS frame ever renders. + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + expect(screen.queryByText('harness')).toBeNull() + // The parent leg settles inside the wait bound: one commit straight to + // the two-pane landing, editor closed. + await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) + expect(columns()).toHaveLength(2) + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The wait-bound timer firing after the landing is a no-op. + await act(async () => { vi.advanceTimersByTime(200) }) + expect(columns()).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it('a stalled parent leg lands the target alone at the wait bound, then upgrades in place', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target can consume most of the outer scan's silence window. + await act(async () => { vi.advanceTimersByTime(250) }) + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + // Its parent leg gets a fresh silence window. Crossing the original + // scan's 300ms deadline therefore cannot flash the indicator during the + // bounded landing wait. + await act(async () => { vi.advanceTimersByTime(199) }) + expect(screen.queryByText('browser.loading')).toBeNull() + // The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone. + await act(async () => { vi.advanceTimersByTime(1) }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('listitem').textContent).toBe('harness') + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The late parent leg still upgrades the landing in place, exactly as + // if it had made the bound. (Reopening the editor meanwhile would + // supersede the upgrade — the editor-open handler withdraws pending + // listings — so a late upgrade can never close a resumed draft.) + await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) + expect(columns()).toHaveLength(2) + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('Escape inside the landing window withdraws the submitted navigation', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: DOCS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + // Nothing has committed yet; Escape supersedes the landing entirely. + fireEvent.keyDown(input, { key: 'Escape' }) + await act(async () => { vi.advanceTimersByTime(200) }) + expect(columns()).toHaveLength(1) + expect(screen.queryByText('harness')).toBeNull() + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => { + vi.useFakeTimers() + try { + // The home level is truncated so its note is on screen when the slow + // scan starts: dropping the note's old !loading guard means it must + // keep rendering through the scan, coexisting with the indicator. + const settlers = new Map void>() + const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => { + if (path === undefined) return Promise.resolve({ ...listingFor(path), truncated: true }) + return new Promise((resolve) => { settlers.set(path, resolve) }) + }) + mount({ listDirectory }) + await act(async () => {}) + expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.getByText('browser.truncated')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // In flight but still inside the silence window: no indicator, and the + // stale level's truncated note stays put (no layout churn on launch). + expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.getByText('browser.truncated')).toBeTruthy() + await act(async () => { vi.advanceTimersByTime(300) }) + // Past it: the indicator floats while the stale level — truncated note + // included — keeps rendering beneath it. + expect(screen.getByText('browser.loading')).toBeTruthy() + expect(screen.getByText('browser.truncated')).toBeTruthy() + expect(screen.getByText('Documents')).toBeTruthy() + // Landing (both legs) retires the indicator with the scan, and the + // fresh listings' own truncated state replaces the stale note. + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) + expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.queryByText('browser.truncated')).toBeNull() + expect(columns()).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it('restarts the silence window when a row pick supersedes a pending scan', async () => { + vi.useFakeTimers() + try { + const pending: ((value: DirectoryListing) => void)[] = [] + const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => { + if (path === undefined) return Promise.resolve(listingFor(path)) + return new Promise((resolve) => { pending.push(resolve) }) + }) + mount({ listDirectory }) + await act(async () => {}) + const documents = rowButton(screen.getByRole('listitem')) + fireEvent.click(documents) + await act(async () => { vi.advanceTimersByTime(300) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + // The same row remains actionable while its preview is pending. A second + // pick starts a new listing without a false `loading` edge. + fireEvent.click(documents) + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(299) }) + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(1) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + await act(async () => { pending.at(-1)!(listingFor(DOCS)) }) + } finally { + vi.useRealTimers() + } + }) + + it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => { + vi.useFakeTimers() + try { + // Every home listing hangs: the initial open's scan is the one the + // close interrupts, and the reopen's scan proves the fresh window. + const settlers: ((value: DirectoryListing) => void)[] = [] + const listDirectory = vi.fn((_path?: string, _signal?: AbortSignal) => + new Promise((resolve) => { settlers.push(resolve) })) + const { view, props } = mount({ listDirectory }) + await act(async () => { vi.advanceTimersByTime(300) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + // Close while the scan is in flight, then reopen: the first frame must + // wait out a fresh silence window, not inherit the armed indicator. + view.rerender() + view.rerender() + await act(async () => {}) + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(300) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + // The reopened scan settles normally. + await act(async () => { settlers.at(-1)!(listingFor(undefined)) }) + expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.getByText('Documents')).toBeTruthy() + } finally { + vi.useRealTimers() + } }) it('keeps the single-pane landing when the truncated parent level lacks the target', async () => { diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 3e5bae41b5..8bb3c0afec 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: 8ef8889c875f5b1d07c015ddef819591041c8d7f -README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d +README.md: 3749b238b56578ec68610bc13550760aa084bad6 +README.zh.md: 488da5129ec211c2a064156c22a9d0abf04d99be diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 8ef8889c87..3749b238b5 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself. Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 8aefffa7b2..488da5129e 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。 +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。 浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 40d5fcf9d3..0160db9f01 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/webserver/README.md -README.md: e715e4452ddb808f36e6b097eee0fda7b8d0bfb0 -README.zh.md: 05e7e10d7815c8f26bb90597b38b7c6b83a86dbc +README.md: ace8c09e43dd8544a28d300f97b04610be78bc69 +README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index e715e4452d..ace8c09e43 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. +Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 05e7e10d78..b9948e3d38 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 +朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 936dd4f5a1..37298cb178 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -78,6 +78,11 @@ export class HttpServerService extends Service { return this.listenedPort } + /** The configured bind host (the loopback or all-interfaces literal). */ + get host(): Config['host'] { + return this.config.host + } + /** * Register a named route. Duplicate (kind, path) throws — route patterns are * a composition-level contract, so a collision is a misconfiguration. diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index c5a13bee7e..a4b6723b98 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: d3c2c14fe616e1c9b4e33b716570b084db6474cf -README.zh.md: 6d6878c4b0300a716ad16be60fd86bc79f1514ba +README.md: 6f0a9ac477b49b96ddfc2ce667e3556dec727569 +README.zh.md: 922b153aa1b08e1a6003f63736ea402787bff1dd diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index d3c2c14fe6..6f0a9ac477 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -14,6 +14,8 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc While active, `plan:policy` renders the configured `section`. The plugin always registers `exit_plan_mode`, keeping tool schemas stable across the transition; its execute path accepts only active plan mode and leaves it only after an exact user approval through `ctx.userInteraction`. +The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message. + When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. The TUI consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary. @@ -77,7 +79,7 @@ The user block is append-only conversation growth. Entering or leaving plan mode #### What the model sees -The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback. +The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback, and a dismissed review a failed call naming the user's takeover. #### Token effect @@ -92,4 +94,5 @@ Mode transitions do not change the tool catalog; plan arguments and review resul - Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls. - A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it. - Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option. -- The `exit_plan_mode` review arc (submit → human review → approved flip or rejected feedback) is covered by package tests only; its assembled-application snapshot left with the retired ACP UI scenarios ([automation-only ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)) and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit. +- The `exit_plan_mode` review arc has one assembled-application snapshot, the Web `plan-review` e2e lane (submit → decision card → approved flip). The rejected-feedback and dismissed branches are covered by package tests only, and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit. +- Only the Web UI renders the `plan-review` intent; the TUI presents the review through its generic question flow, which is answerable but does not read as a plan gate. diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 6d6878c4b0..922b153aa1 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -14,6 +14,8 @@ 激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userInteraction` 获得精确用户批准后才退出。 +评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。 + 组合 `ctx.commands` 时,该包(package)会注册 `/plan [message]`,并保留精确参数 `off` 用于直接退出。不带参数的 `/plan` 选择 plan mode;任何其他非空参数都会先选择 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 选择未激活状态,不发送模型输入;它还可以在 plan mode 进入选择到达请求之前取消该待生效选择。 TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 @@ -77,7 +79,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型所见内容 -[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用。 +[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。 #### Token 影响 @@ -92,4 +94,5 @@ Mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩 - Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。 - 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。 - Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。 -- `exit_plan_mode` 评审弧(提交 → 人类评审 → 已批准切换或已拒绝反馈)仅由包测试覆盖;其组装应用快照随已退役 ACP UI 场景一起离开([仅面向自动化的 ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)),TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。 +- `exit_plan_mode` 评审弧有一个组装应用快照,即 Web `plan-review` e2e 通道(提交 → 决定卡片 → 已批准切换)。已拒绝反馈与放弃审阅两个分支仅由包测试覆盖,TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。 +- 只有 Web UI 渲染 `plan-review` 意图;TUI 通过其通用问题流程呈现该评审,可以回答,但读起来不像一个计划关口。 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index dc6b788825..b2a5f6076d 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -29,7 +29,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-user-interaction' +import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' // Type-only edge: resolves `ctx.commands` for the optional command child. import type {} from '@deepseek-ai/dsh-commands' // Type-only: resolves ctx.sessionProjections for the optional unit child. @@ -70,6 +70,9 @@ export interface PlanModeConfig { section: string } +/** The review question's id, echoed in the answer this tool reads. */ +const REVIEW_ID = 'plan-review' + /** The review question's approve option label. */ const APPROVE_LABEL = 'Approve' @@ -317,7 +320,7 @@ export class PlanModeService extends Service { } const answer = await interaction.ask({ questions: [{ - id: 'plan-review', + id: REVIEW_ID, header: 'Plan review', question: 'Approve this plan and leave plan mode?', detail: args.plan, @@ -325,16 +328,31 @@ export class PlanModeService extends Service { { label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' }, { label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' }, ], + // Presentation only: a capable UI renders the plan as a review + // decision instead of a generic question, and answers with one of + // the labels above either way. + intent: { kind: 'plan-review', approve: APPROVE_LABEL }, }], agent, signal: exec.signal, + }).catch((cause: unknown) => { + // A dismissed review is not a failed one: the user took the turn back + // to say something the two options do not cover. Say so, because the + // generic channel message names ask_user_question, which the model + // never called. An abort (turn cancel, provider teardown) keeps its + // own message — there is no user to wait for. + if (cause instanceof UserInteractionError && cause.code === 'ASK_CANCELLED') { + throw new Error('The user dismissed the plan review to speak instead; ' + + 'stay in plan mode, stop here, and wait for their message.') + } + throw cause }) // A review may outlive this plugin fiber. Without boundary listeners, // an approved result could never land, so fail and keep planning. if (disposed) { throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again') } - const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review') + const reviewItems = answer.answers.filter(entry => entry.id === REVIEW_ID) const item = reviewItems.length === 1 ? reviewItems[0] : undefined if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) { const feedback = item?.custom ?? '' diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 72d273d20f..6fa165cbb0 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -6,7 +6,9 @@ import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek import { Session, SessionId } from '@deepseek-ai/dsh-session' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' -import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' +import UserInteractionService, { + UserInteractionError, type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-interaction' import CommandService from '@deepseek-ai/dsh-commands' import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts' @@ -894,6 +896,40 @@ describe('exit_plan_mode', () => { expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) }) + it('declares the plan-review presentation intent naming its approve option', async () => { + const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] }) + await callExit(ctx, agent) + const question = asked[0]?.questions[0] + expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' }) + // The named label is one this same question offers, so a UI honouring the + // intent answers a choice this tool accepts. + expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve) + }) + + it('reads a dismissed review as the user taking the turn back, not as a failure', async () => { + const { ctx, agent } = await setupWithReview() + ctx.userInteraction.registerProvider({ + ask: () => Promise.reject(new UserInteractionError( + 'the user cancelled ask_user_question', 'ASK_CANCELLED')), + }) + const result = await callExit(ctx, agent) + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }]) + expect(foldPlanMode(agent.session.events)).toBe(true) + }) + + it('leaves every other review failure its own message', async () => { + const { ctx, agent } = await setupWithReview() + ctx.userInteraction.registerProvider({ + ask: () => Promise.reject(new UserInteractionError( + 'ask_user_question was aborted before the user answered', 'ASK_ABORTED')), + }) + const result = await callExit(ctx, agent) + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: ask_user_question was aborted before the user answered' }]) + expect(foldPlanMode(agent.session.events)).toBe(true) + }) + it('forwards the execution abort signal to the review question', async () => { const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] }) const controller = new AbortController() diff --git a/packages/settings/README.i18n.yaml b/packages/settings/README.i18n.yaml new file mode 100644 index 0000000000..7c78505615 --- /dev/null +++ b/packages/settings/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/settings/README.md +README.md: 7a91355dd01805938944f0abce77765021288e6d +README.zh.md: 2df40b67eb8ce6cfc693ed6bf3574815c0219ec0 diff --git a/packages/settings/README.md b/packages/settings/README.md new file mode 100644 index 0000000000..7a91355dd0 --- /dev/null +++ b/packages/settings/README.md @@ -0,0 +1,12 @@ +# settings/ — user-settings capability family + +English | [中文](README.zh.md) + +The user-settings seam and its providers. The interface package owns the abstract `Settings` service — namespace registration, layered resolution, and change commits; providers implement raw-document storage and push external edits through the seam. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `settings/` | Settings seam: namespace registry, layered resolution, commit events | `ctx.settings` | +| `settings-local/` | File-backed provider (`settings.yaml`/`.json`) with hot reload and comment-preserving write-back | (registers `ctx.settings`) | + +The interface lives at `settings/settings/`; providers are flat siblings. A network configuration-center provider (for example a nacos-style backend) joins here and registers on `ctx.settings`. Composition config stays in `cordis.yml`: a settings namespace carries only the user-editable subset, resolved as schema defaults, then the registrant's composition `base`, then the user document. diff --git a/packages/settings/README.zh.md b/packages/settings/README.zh.md new file mode 100644 index 0000000000..2df40b67eb --- /dev/null +++ b/packages/settings/README.zh.md @@ -0,0 +1,12 @@ +# settings/ — 用户设置能力族 + +[English](README.md) | 中文 + +用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交;provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。 + +| 包 | 角色 | ctx key | +|---|---|---| +| `settings/` | 设置 seam:namespace 注册表、分层解析、提交事件 | `ctx.settings` | +| `settings-local/` | 文件 provider(`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings`) | + +接口位于 `settings/settings/`;provider 平级并列。网络配置中心 provider(例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`:settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。 diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml new file mode 100644 index 0000000000..9638a62f96 --- /dev/null +++ b/packages/settings/settings-local/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/settings/settings-local/README.md +README.md: 2c0817afd2f2fd35fda2d22cd7f7ef3772fe2257 +README.zh.md: 547abb035368f07d4478a5c3a1793cdaa6743c68 diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md new file mode 100644 index 0000000000..2c0817afd2 --- /dev/null +++ b/packages/settings/settings-local/README.md @@ -0,0 +1,43 @@ +# @deepseek-ai/dsh-settings-local + +English | [中文](README.zh.md) + +File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` re-reads the document under a writer lock before writing back atomically, preserving the user's YAML comments, any section owned by a plugin that is not currently loaded, and any on-disk change this process has not observed yet. + +## Config + +| Field | Meaning | Default | +|---|---|---| +| `path` | Settings document path; extension picks the format (`.yaml`/`.yml`/`.json`) | `settings.yaml` under the harness home | +| `dshHome` | Harness home used when `path` is omitted | `$DSH_HOME` or `~/.dsh` | +| `watch` | Watch the document and hot-publish external edits | `true` | +| `debounceMs` | Watcher write-settle window in milliseconds | `100` | + +Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension fails at load. + +## Behavior + +- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state. +- **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit. +- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `.lock` sibling with exponential backoff, a 2 s acquisition deadline (the write rejects), and stale-lock takeover after 5 s (a crashed holder, broken with a warning). Readers never take the lock: the rename commit is atomic, so reloads are always consistent. +- **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure. +- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments. +- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. +- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap. +- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal. +- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. + +## Model Experience + +Indirectly, through consumers of `ctx.settings`: this provider only stores and publishes namespace sections, and each consumer's own surface documents any model effect. + +#### KV Cache effect + +No direct invalidation; the consuming plugin owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check. +- **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart. +- **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described. +- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md new file mode 100644 index 0000000000..547abb0353 --- /dev/null +++ b/packages/settings/settings-local/README.zh.md @@ -0,0 +1,43 @@ +# @deepseek-ai/dsh-settings-local + +[English](README.md) | 中文 + +文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 在写锁下先重读文档再原子写回,保留用户的 YAML 注释、当前未加载插件所拥有的分节,以及任何本进程尚未观察到的磁盘变更。 + +## 配置 + +| 字段 | 含义 | 默认 | +|---|---|---| +| `path` | 设置文档路径;扩展名决定格式(`.yaml`/`.yml`/`.json`) | harness home 下的 `settings.yaml` | +| `dshHome` | `path` 省略时使用的 harness home | `$DSH_HOME` 或 `~/.dsh` | +| `watch` | 监听文档并热发布外部编辑 | `true` | +| `debounceMs` | watcher 写入稳定窗口(毫秒) | `100` | + +默认值解析是一步显式的 `resolveSpec(config)`;不支持的扩展名在加载时报错。 + +## 行为 + +- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。 +- **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。 +- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `.lock` 同级文件下运行,带指数退避、2 s 的获取期限(到期则写入拒绝)与 5 s 后的陈旧锁接管(持有者已崩溃,破锁并告警)。读取方从不取锁:rename 提交是原子的,重载因此始终一致。 +- **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。 +- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。 +- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 +- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。 +- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。 +- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 + +## Model Experience + +间接生效:本 provider 只存储并发布 namespace 分节,模型效果经由 `ctx.settings` 的消费插件产生,由各消费者自己的文档描述。 + +#### KV Cache effect + +无直接失效;请求前缀的变更由消费插件拥有。 + +## Known Limitations and Deferred Work + +- **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。 +- **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。 +- **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。 +- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。 diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json new file mode 100644 index 0000000000..aefb1ccd33 --- /dev/null +++ b/packages/settings/settings-local/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-settings-local", + "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "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" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "chokidar": "^4.0.3", + "schemastery": "^3.18.0", + "yaml": "^2.9.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts new file mode 100644 index 0000000000..d0d1497b16 --- /dev/null +++ b/packages/settings/settings-local/src/index.ts @@ -0,0 +1,411 @@ +/** + * File-backed settings provider. One YAML or JSON document under the user's + * harness home carries every namespace section; external edits hot-publish + * through the seam, and every write re-reads the document under a + * cross-process writer lock before patching it as a comment-preserving + * leaf-level diff. + * @module @deepseek-ai/dsh-settings-local + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { watch as chokidarWatch } from 'chokidar' +import { randomBytes } from 'node:crypto' +import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, extname, join, resolve } from 'node:path' +import { Document, parseDocument } from 'yaml' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' + +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Settings document path; defaults to `settings.yaml` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} + +/** Document format derived from the configured file extension. */ +type SettingsFormat = 'yaml' | 'json' + +const FORMATS: Record = { + '.yaml': 'yaml', + '.yml': 'yaml', + '.json': 'json', +} + +/** Fully resolved provider parameters; defaulting happens here, never inline. */ +interface ResolvedSpec { + filename: string + format: SettingsFormat + watch: boolean + debounceMs: number +} + +/** + * Resolve the runtime spec from plugin config: an explicit `path` wins, + * otherwise the document lives at `/settings.yaml`. + * @param config - raw plugin config. + * @returns the resolved file location, format, and watch behavior. + */ +export function resolveSpec(config: Config): ResolvedSpec { + const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml')) + const format = FORMATS[extname(filename)] + if (format === undefined) { + throw new Error(`settings-local: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`) + } + return { + filename, + format, + watch: config.watch ?? true, + debounceMs: config.debounceMs ?? 100, + } +} + +/** Whether a parsed YAML value is a map for diffing purposes. */ +function isMapLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Apply the difference between one node's stored and next value as minimal + * `setIn`/`deleteIn` edits, recursing through maps, so every untouched node — + * and the key node of every changed pair — keeps its comments, anchors, and + * formatting. Non-map values (arrays and scalars) replace wholesale when + * unequal, taking any comments inside them along. + */ +function patchNode(document: Document, path: readonly string[], current: unknown, next: unknown): void { + if (isMapLike(current) && isMapLike(next)) { + for (const key of Object.keys(current)) { + if (!(key in next)) document.deleteIn([...path, key]) + } + for (const [key, value] of Object.entries(next)) { + patchNode(document, [...path, key], current[key], value) + } + return + } + if (!deepEqualJson(current, next)) document.setIn([...path], next) +} + +/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ +function isENOENT(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +/** Whether an exclusive create failed because the path already exists. */ +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + +/** + * Writer-lock protocol constants. These are robustness invariants of the + * cross-process write protocol, not deployment tunables: a holder rewrites one + * small document in milliseconds, so contention resolves well inside the + * retry deadline, and a lock older than the stale age can only belong to a + * crashed holder. + */ +const LOCK_RETRY_INITIAL_MS = 20 +const LOCK_RETRY_MAX_MS = 200 +const LOCK_TIMEOUT_MS = 2_000 +const LOCK_STALE_MS = 5_000 + +/** File-backed settings provider (`settings.yaml`/`.json`). */ +export class SettingsLocal extends Settings { + static Config: z = z.object({ + path: z.string(), + dshHome: z.string(), + watch: z.boolean().default(true), + debounceMs: z.number().min(0).default(100), + }) + + private readonly spec: ResolvedSpec + /** + * Raw text of the last successfully parsed or persisted document; + * `undefined` while the file is absent. Watcher events whose content equals + * this cache are no-ops, which is also the self-write suppression. + */ + private text: string | undefined + /** + * Single exclusive operation chain: watcher reloads and document writes run + * one at a time in queue order (settled tail), so a write can never render + * from text a concurrent reload is busy replacing, and a reload can never + * read a half-committed write. + */ + private operations: Promise = Promise.resolve() + /** Set at dispose: refuse new watcher events and let in-flight work no-op. */ + private closed = false + + /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */ + private isClosed(): boolean { + return this.closed + } + + constructor(ctx: Context, public config: Config) { + super(ctx) + // Programmatic construction may bypass Schemastery normalization; resolve + // the same defaults in one explicit step either way. + this.spec = resolveSpec(config) + } + + /** The local document is always writable through {@link Settings.update}. */ + get writable(): boolean { + return true + } + + protected async load(): Promise> { + let text: string + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) throw error + this.text = undefined + return {} + } + const doc = this.parse(text) + this.text = text + return doc + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + // One document backs every namespace, so writes from different namespace + // queues serialize with each other and with watcher reloads on the one + // operation chain: each render must see the text the previous operation + // committed, or a sibling section silently vanishes from disk. + return this.enqueue(() => this.persistSection(ns, section)) + } + + /** Queue one exclusive document operation behind every earlier one. */ + private enqueue(operation: () => Promise): Promise { + const task = this.operations.then(operation) + this.operations = task.then(() => undefined, () => undefined) + return task + } + + /** Queue a reload; only an invariant violation escaping a commit can reject it. */ + private queueRefresh(): void { + void this.enqueue(() => this.refresh()).catch((error: unknown) => { + // Only an invariant violation escaping the commit path can reject a + // refresh; keep the operation queue alive and surface it as an error so + // one poisoned commit cannot silently end hot reloading forever. + this.ctx.logger.error('settings-local: reload commit failed at %s', this.spec.filename) + this.ctx.logger.error(error) + }) + } + + private async persistSection(ns: SettingsNamespace, section: Record): Promise { + await mkdir(dirname(this.spec.filename), { recursive: true }) + await this.withWriterLock(async () => { + // Read-modify-write: fold in any on-disk state this process has not + // observed yet — an external edit still inside the watcher debounce + // window, a change the watcher missed, or another process's write — so + // the render below can never resurrect a stale document. An unparsable + // on-disk document fails the write loud instead of silently overwriting + // a user's manual edit. + await this.reconcileFromDisk() + const output = this.spec.format === 'yaml' + ? this.renderYaml(ns, section) + : this.renderJson(ns, section) + // Exclusive-create (`wx`) a random-suffix sibling: the open refuses to + // follow any planted symlink at a guessable temp path, and the fresh inode + // carries owner-only permissions that survive the rename — a document that + // may hold personal values is never world-readable and never a symlink. + const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp` + // TODO(settings-atomic-durability): Use a replacement that fsyncs the file + // and parent directory and preserves owner-only permissions on Windows. + try { + await writeFile(temp, output, { mode: 0o600, flag: 'wx' }) + await rename(temp, this.spec.filename) + } catch (error) { + await rm(temp, { force: true }) + throw error + } + this.text = output + }) + } + + /** + * Hold the cross-process writer lock around one read-render-rename cycle. + * The lock is a `wx`-created sibling (`.lock`); the rename-based + * commit keeps readers lock-free, so only writers contend. A lock older + * than {@link LOCK_STALE_MS} is a crashed holder and is broken with a + * warning; a live holder past {@link LOCK_TIMEOUT_MS} fails the write. + */ + private async withWriterLock(operation: () => Promise): Promise { + const lockPath = `${this.spec.filename}.lock` + const deadline = Date.now() + LOCK_TIMEOUT_MS + let delay = LOCK_RETRY_INITIAL_MS + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) + break + } catch (error) { + if (!isEEXIST(error)) throw error + } + const ageMs = await this.lockAgeMs(lockPath) + // The holder released between the failed create and the stat: the lock + // is free right now, so retry without burning backoff or deadline. + if (ageMs === undefined) continue + if (ageMs > LOCK_STALE_MS) { + // TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe + // acquisition and release so a slow writer cannot remove a successor's lock. + this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) + await rm(lockPath, { force: true }) + continue + } + if (Date.now() >= deadline) { + throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`) + } + await new Promise(resolve => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) + } + try { + return await operation() + } finally { + await rm(lockPath, { force: true }) + } + } + + /** Age of the writer lock, or `undefined` when it vanished after a failed create. */ + private async lockAgeMs(lockPath: string): Promise { + try { + return Date.now() - (await stat(lockPath)).mtimeMs + } catch (error) { + if (!isENOENT(error)) throw error + return undefined + } + } + + override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { + // The base init loads and publishes; a parse failure there is a boot + // failure: an existing-but-invalid document must fail loud, never be + // silently ignored or overwritten. + yield* super[Service.init]() + if (!this.spec.watch) return + const watcher = chokidarWatch(this.spec.filename, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: this.spec.debounceMs, + pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), + }, + }) + watcher.on('all', () => { + if (this.closed) return + this.queueRefresh() + }) + watcher.on('ready', () => { + // The base init's load raced the watcher's own setup: a change written + // between that read and the watcher becoming active never fires an + // event. One reconcile at ready closes the gap. + if (this.closed) return + this.queueRefresh() + }) + watcher.on('error', (error) => { + this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename) + this.ctx.logger.warn(error) + }) + yield async () => { + // Quiesce: stop accepting events, close the watcher, then wait out any + // queued or in-flight operation so nothing publishes after disposal. + this.closed = true + await watcher.close() + await this.operations + } + } + + /** Parse one document text into raw sections, failing on a non-map root. */ + private parse(text: string): Record { + let root: unknown + if (this.spec.format === 'yaml') { + const document = parseDocument(text, { prettyErrors: true }) + if (document.errors.length > 0) { + throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${ + document.errors.map(error => error.message).join('; ')}`) + } + root = document.toJS() ?? {} + } else { + root = text.trim().length === 0 ? {} : JSON.parse(text) + } + if (typeof root !== 'object' || root === null || Array.isArray(root)) { + throw new TypeError(`settings-local: ${this.spec.filename} must be a map of namespace sections`) + } + return root as Record + } + + /** + * Re-read the document after a watcher event. Unchanged content (including + * this provider's own writes) is a no-op; an unreadable or unparsable + * document keeps the last good sections and warns — a live hot-reload must + * never take the process down. An invariant violation escaping a commit is + * not a reload failure and propagates to the queue's error surface. + */ + private async refresh(): Promise { + if (this.closed) return + try { + await this.reconcileFromDisk() + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error + this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + } + } + + /** + * Compare the on-disk text against the cache and publish any difference + * into the seam. Absence publishes the empty document; an unreadable or + * unparsable file throws, so each caller picks its policy — a reload warns + * and keeps the last good document, a write fails loud. + */ + private async reconcileFromDisk(): Promise { + let text: string | undefined + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) throw error + text = undefined + } + if (text === this.text || this.isClosed()) return + if (text === undefined) { + this.text = undefined + this.publish({}) + return + } + const doc = this.parse(text) + this.text = text + this.publish(doc) + } + + /** + * Render the next YAML text by patching one namespace in the + * comment-preserving document. The next section lands as a leaf-level diff + * against the stored one — only changed values set, only removed keys + * delete — so comments inside the section survive edits to their siblings, + * not just comments outside it. + */ + private renderYaml(ns: SettingsNamespace, section: Record): string { + if (this.text === undefined) { + return new Document({ [ns]: section }).toString() + } + // this.text only ever caches content that parsed successfully, so this + // re-parse (for the mutable comment-preserving tree) cannot fail, and + // parse() already rejected any non-map root. + const document = parseDocument(this.text) + const root: unknown = document.toJS() + patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section) + return document.toString() + } + + /** Render the next JSON text by replacing one namespace key. */ + private renderJson(ns: SettingsNamespace, section: Record): string { + const root = this.text === undefined + ? {} + : this.parse(this.text) + root[ns] = section + return `${JSON.stringify(root, null, 2)}\n` + } +} + +export default SettingsLocal diff --git a/packages/settings/settings-local/src/invariant.ts b/packages/settings/settings-local/src/invariant.ts new file mode 100644 index 0000000000..b59b798298 --- /dev/null +++ b/packages/settings/settings-local/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-settings-local`. + * @module @deepseek-ai/dsh-settings-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local' + +/** Cordis companion plugin name. */ +export const name = 'settings-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this provider's contracts are file round-trip, + * watcher timing, and atomic-write behavior — IO effects proven by package + * tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/settings/settings-local/tests/concurrency.spec.ts b/packages/settings/settings-local/tests/concurrency.spec.ts new file mode 100644 index 0000000000..ab09866819 --- /dev/null +++ b/packages/settings/settings-local/tests/concurrency.spec.ts @@ -0,0 +1,103 @@ +// Cross-instance and writer-lock behavior: two providers on one document are +// the in-process equivalent of two dsh processes sharing a harness home — +// neither knows the other's cache, so only the read-modify-write cycle under +// the `.lock` sibling keeps both namespaces alive on disk. +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { chmod, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '../src/index.ts' + +const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) }) +const BetaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) }) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lock-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('cross-instance writes', () => { + it('keeps both namespaces when two providers write the same document concurrently', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const first = await boot({ path, watch: false }) + const second = await boot({ path, watch: false }) + const alpha = first.settings.register(settingsNamespace('alpha'), AlphaSchema) + const beta = second.settings.register(settingsNamespace('beta'), BetaSchema) + const rounds = [1, 2, 3, 4, 5] + await Promise.all([ + (async () => { for (const value of rounds) await alpha.update({ value }) })(), + (async () => { for (const value of rounds) await beta.update({ value }) })(), + ]) + const text = await readFile(path, 'utf8') + expect(text).toContain('alpha:') + expect(text).toContain('beta:') + // A third instance resolves both final values from the shared document. + const third = await boot({ path, watch: false }) + expect(third.settings.register(settingsNamespace('alpha'), AlphaSchema).get()).toEqual({ value: 5 }) + expect(third.settings.register(settingsNamespace('beta'), BetaSchema).get()).toEqual({ value: 5 }) + }) +}) + +describe('writer lock', () => { + it('waits for a busy writer lock instead of failing', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + await writeFile(`${path}.lock`, 'holder\n') + const release = setTimeout(() => { void rm(`${path}.lock`, { force: true }) }, 120) + cleanups.push(async () => { clearTimeout(release) }) + await scope.update({ value: 7 }) + expect(await readFile(path, 'utf8')).toContain('value: 7') + }) + + it('breaks a stale writer lock with a warning and writes through', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + await writeFile(`${path}.lock`, 'crashed-holder\n') + const past = (Date.now() - 60_000) / 1000 + await utimes(`${path}.lock`, past, past) + await scope.update({ value: 9 }) + expect(await readFile(path, 'utf8')).toContain('value: 9') + }) + + it('times out on a lock a live holder never releases', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + await writeFile(`${path}.lock`, 'busy-holder\n') + await expect(scope.update({ value: 1 })).rejects.toThrow(/timed out waiting for the writer lock/) + }, 10_000) + + it('surfaces a non-contention lock failure as the write error', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + await chmod(dir, 0o500) + cleanups.push(() => chmod(dir, 0o700)) + await expect(scope.update({ value: 1 })).rejects.toThrow(/EACCES|permission/) + }) +}) diff --git a/packages/settings/settings-local/tests/loader-composition.spec.ts b/packages/settings/settings-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..c7cea89e41 --- /dev/null +++ b/packages/settings/settings-local/tests/loader-composition.spec.ts @@ -0,0 +1,145 @@ +/** + * Real-composition guard: the provider and a consumer plugin boot from a + * test-only cordis.yml through the actual Loader + Include path, an external + * edit of settings.yaml hot-publishes into the consumer's scope, and the same + * consumer booted WITHOUT a settings entry keeps its entry-config resolution — + * the documented optional-inject fallback. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import z from 'schemastery' +import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings' +import SettingsLocal from '../src/index.ts' + +interface ThemeConfig { + theme: 'dark' | 'light' + fontSize: number +} + +const ThemeSchema: z = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +interface ConsumerState { + scope: SettingsScope | undefined + seen: ThemeConfig[] + /** What the consumer is actually running with, settings or not. */ + applied: ThemeConfig | undefined +} + +async function loadComposition( + options?: { withSettings?: boolean }, +): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> { + const withSettings = options?.withSettings ?? true + root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-')) + const settingsPath = join(root, 'settings.yaml') + await writeFile(settingsPath, 'ui-theme:\n theme: light\n') + + const state: ConsumerState = { scope: undefined, seen: [], applied: undefined } + const consumer = { + name: 'settings-consumer', + apply: (ctx: Context) => { + // The documented consumer shape: no hard dependency — entry config alone + // is the running state, and the scoped inject overlays the user layer + // only while a settings service exists. + const base: Partial = { fontSize: 16 } + state.applied = ThemeSchema(base as ThemeConfig) + ctx.inject(['settings'], (child: Context) => { + const scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { base }) + state.scope = scope + state.applied = scope.get() + scope.watch((next) => { + state.seen.push(next) + state.applied = next + }) + }) + }, + } + + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + ...withSettings + ? [ + '- id: settings', + " name: '@deepseek-ai/dsh-settings-local'", + ' config:', + ` path: ${JSON.stringify(settingsPath)}`, + ' debounceMs: 10', + ] + : [], + '- id: consumer', + ' name: test-settings-consumer', + '', + ].join('\n')) + + const ctx = new Context() + context = ctx + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-settings-local', SettingsLocal], + ['test-settings-consumer', consumer], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + return { ctx, state, settingsPath } +} + +describe('settings-local real composition', () => { + it('boots from cordis.yml and hot-publishes an external settings edit', async () => { + const { ctx, state, settingsPath } = await loadComposition() + + // Composition resolution: user layer over the consumer's composition base. + await vi.waitFor(() => { + expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 }) + }) + expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme']) + + await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n') + await vi.waitFor(() => { + expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 }) + }, { timeout: 5000 }) + expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 }) + }) + + it('boots the same consumer without a settings entry and keeps entry-config resolution', async () => { + const { ctx, state } = await loadComposition({ withSettings: false }) + + // No settings service anywhere in the composition… + expect(ctx.get('settings')).toBeUndefined() + // …so the consumer runs on schema defaults plus its composition base, and + // never receives a scope. + expect(state.applied).toEqual({ theme: 'dark', fontSize: 16 }) + expect(state.scope).toBeUndefined() + expect(state.seen).toEqual([]) + }) +}) diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts new file mode 100644 index 0000000000..0b753675f5 --- /dev/null +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -0,0 +1,401 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal, resolveSpec } from '../src/index.ts' + +interface ThemeConfig { + theme: 'dark' | 'light' + fontSize: number +} + +const ThemeSchema: z = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('resolveSpec', () => { + it('defaults watch and debounce when construction bypasses schema normalization', () => { + const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' }) + expect(spec.watch).toBe(true) + expect(spec.debounceMs).toBe(100) + }) +}) + +describe('boot and reads', () => { + it('resolves defaults over an absent file and reports writable', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) + expect(ctx.settings.writable).toBe(true) + }) + + it('reads sections from an existing yaml document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) + }) + + it('reads sections from a json document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.json') + await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } })) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) + }) + + it('defaults the file location under the configured harness home', async () => { + const dir = await tempDir() + const ctx = await boot({ dshHome: dir, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + const written = await readFile(join(dir, 'settings.yaml'), 'utf8') + expect(written).toContain('theme: light') + }) + + it('reads an empty yaml document as no sections', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, '') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }) + + it('reads an empty json document as no sections', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.json') + await writeFile(path, '') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }) + + it('fails loud at boot when the document exists but is unreadable', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + await chmod(path, 0o000) + cleanups.push(() => chmod(path, 0o600)) + await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i) + }) + + it('fails loud on an unsupported extension', async () => { + const dir = await tempDir() + await expect(boot({ path: join(dir, 'settings.toml'), watch: false })) + .rejects.toThrow(/not supported/) + }) + + it('fails loud at boot on unparsable yaml', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme: [unclosed\n') + await expect(boot({ path, watch: false })).rejects.toThrow() + }) + + it('fails loud at boot when the root is not a map of sections', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, '- just\n- a list\n') + await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/) + }) +}) + +describe('persist', () => { + it('writes the merged section, creating the file with owner-only permissions', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + + const written = await readFile(path, 'utf8') + expect(written).toContain('theme: light') + expect((await stat(path)).mode & 0o777).toBe(0o600) + // Atomic replace leaves no temp artifact behind. + expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) + }) + + it('serializes cross-namespace writes into one on-disk document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const alpha = ctx.settings.register(settingsNamespace('alpha'), ThemeSchema) + const beta = ctx.settings.register(settingsNamespace('beta'), ThemeSchema) + await Promise.all([ + alpha.update({ theme: 'light' }), + beta.update({ fontSize: 20 }), + ]) + const text = await readFile(path, 'utf8') + expect(text).toContain('alpha:') + expect(text).toContain('beta:') + expect(alpha.get().theme).toBe('light') + expect(beta.get().fontSize).toBe(20) + }) + + it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const victim = join(dir, 'victim.txt') + await writeFile(victim, 'precious') + // A hostile sibling plants the historic fixed temp name as a symlink. + await symlink(victim, `${path}.tmp`) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + + expect(await readFile(victim, 'utf8')).toBe('precious') + expect((await lstat(path)).isSymbolicLink()).toBe(false) + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect(await readFile(path, 'utf8')).toContain('theme: light') + }) + + it('preserves comments and unregistered sections across updates', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, [ + '# personal settings', + 'ui-theme:', + ' theme: light', + '# owned by a plugin that is not loaded right now', + 'future-plugin:', + ' keep: me', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ fontSize: 18 }) + + const written = await readFile(path, 'utf8') + expect(written).toContain('# personal settings') + expect(written).toContain('# owned by a plugin that is not loaded right now') + expect(written).toContain('keep: me') + expect(written).toContain('fontSize: 18') + expect(written).toContain('theme: light') + }) + + it('keeps comments inside the section when a sibling key changes', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, [ + 'ui-theme:', + ' # chosen during onboarding', + ' theme: light', + ' fontSize: 12', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ fontSize: 18 }) + const written = await readFile(path, 'utf8') + expect(written).toContain('# chosen during onboarding') + expect(written).toContain('theme: light') + expect(written).toContain('fontSize: 18') + }) + + it('keeps a changed key\'s own-line comment while replacing its value', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, [ + 'ui-theme:', + ' # chosen during onboarding', + ' theme: light', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'dark' }) + const written = await readFile(path, 'utf8') + expect(written).toContain('# chosen during onboarding') + expect(written).toContain('theme: dark') + }) + + it('deletes only the removed key on replace, keeping sibling comments', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, [ + 'ui-theme:', + ' # chosen during onboarding', + ' theme: light', + ' fontSize: 12', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.replace({ theme: 'light' }) + const written = await readFile(path, 'utf8') + expect(written).toContain('# chosen during onboarding') + expect(written).toContain('theme: light') + expect(written).not.toContain('fontSize') + }) + + it('keeps an unchanged array\'s comments and replaces a changed array wholesale', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const TagsSchema: z<{ tags: string[]; label: string }> = z.object({ + tags: z.array(z.string()).default([]), + label: z.string().default(''), + }) + await writeFile(path, [ + 'workspace:', + ' tags:', + ' # pinned by hand', + ' - alpha', + ' label: draft', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('workspace'), TagsSchema) + await scope.update({ label: 'final' }) + const untouched = await readFile(path, 'utf8') + expect(untouched).toContain('# pinned by hand') + expect(untouched).toContain('label: final') + // A changed array replaces wholesale; comments inside it go with it. + await scope.update({ tags: ['beta'] }) + const replaced = await readFile(path, 'utf8') + expect(replaced).not.toContain('# pinned by hand') + expect(replaced).toContain('- beta') + }) + + it('keeps a comment-only document\'s comment when the first section lands', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + // Parses to a null root: the document exists but holds no sections yet. + await writeFile(path, '# reserved for future settings\n') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + const written = await readFile(path, 'utf8') + expect(written).toContain('# reserved for future settings') + expect(written).toContain('theme: light') + }) + + it('creates a json document from scratch', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.json') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + const written = JSON.parse(await readFile(path, 'utf8')) as Record + expect(written).toEqual({ 'ui-theme': { theme: 'light' } }) + }) + + it('rejects and leaves no temp residue when the directory turns unwritable', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await chmod(dir, 0o500) + cleanups.push(() => chmod(dir, 0o700)) + await expect(scope.update({ theme: 'dark' })).rejects.toThrow() + await chmod(dir, 0o700) + expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) + expect(scope.get().theme).toBe('light') + // The failed persist must not poison the document write chain. + await scope.update({ theme: 'dark' }) + expect(scope.get().theme).toBe('dark') + }) + + it('round-trips a json document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.json') + await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2)) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + const written = JSON.parse(await readFile(path, 'utf8')) as Record + expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } }) + }) +}) + +describe('watch', () => { + it('publishes an external edit to registered scopes', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 10 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get().theme).toBe('light') + + await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n') + await vi.waitFor(() => { + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 }) + }, { timeout: 5000 }) + }) + + it('keeps the last good document over an invalid edit, then recovers', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 10 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + + await writeFile(path, 'ui-theme: [unclosed\n') + // The bad edit must never take the live tree down or reset the value. + await new Promise(resolve => setTimeout(resolve, 300)) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) + + await writeFile(path, 'ui-theme:\n theme: dark\n') + await vi.waitFor(() => { + expect(scope.get().theme).toBe('dark') + }, { timeout: 5000 }) + }) + + it('treats file removal as an empty document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 10 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + + await rm(path) + await vi.waitFor(() => { + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }, { timeout: 5000 }) + }) + + it('does not republish its own persisted write', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, debounceMs: 10 }) + const events: unknown[] = [] + ctx.on('settings/updated', (ns, _next, _prev, source) => { + events.push({ ns, source }) + }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + await new Promise(resolve => setTimeout(resolve, 300)) + expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }]) + }) +}) diff --git a/packages/settings/settings-local/tests/lock-race.spec.ts b/packages/settings/settings-local/tests/lock-race.spec.ts new file mode 100644 index 0000000000..09eb025654 --- /dev/null +++ b/packages/settings/settings-local/tests/lock-race.spec.ts @@ -0,0 +1,100 @@ +// Writer-lock races that cannot be timed from outside: a contender whose lock +// vanishes between the failed exclusive create and the stat, a stat failing +// for a reason other than absence, and a temp-file write failing mid-cycle. +// The fs/promises seam is partially mocked to inject exactly one failure at a +// chosen path suffix; everything else passes through to the real filesystem. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '../src/index.ts' + +const state = vi.hoisted(() => ({ + /** One-shot failure injections keyed by operation, matched on a path suffix. */ + failures: [] as Array<{ op: 'writeFile' | 'stat'; suffix: string; code: string }>, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + const inject = (op: 'writeFile' | 'stat', path: unknown): void => { + const index = state.failures.findIndex(f => f.op === op && String(path).endsWith(f.suffix)) + if (index === -1) return + const [failure] = state.failures.splice(index, 1) + throw Object.assign(new Error(`${failure!.code}: injected ${op} failure`), { code: failure!.code }) + } + return { + ...actual, + writeFile: (async (path: unknown, ...rest: never[]) => { + inject('writeFile', path) + return (actual.writeFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.writeFile, + stat: (async (path: unknown, ...rest: never[]) => { + inject('stat', path) + return (actual.stat as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.stat, + } +}) + +const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) }) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + state.failures.length = 0 + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lockrace-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('writer-lock races', () => { + it('retries immediately when the contending lock vanished before the stat', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + // The exclusive create loses to a holder that releases before the stat: + // no lock file actually exists, so the stat sees honest absence and the + // very next attempt takes the lock. + state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' }) + await scope.update({ value: 3 }) + expect(await readFile(path, 'utf8')).toContain('value: 3') + }) + + it('propagates a stat failure that does not mean absence', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' }) + state.failures.push({ op: 'stat', suffix: '.lock', code: 'EACCES' }) + await expect(scope.update({ value: 3 })).rejects.toThrow(/EACCES/) + }) + + it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'alpha:\n value: 1\n') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + state.failures.push({ op: 'writeFile', suffix: '.tmp', code: 'ENOSPC' }) + await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/) + // The document is untouched and the writer lock was released on the way out. + expect(await readFile(path, 'utf8')).toContain('value: 1') + await expect(access(`${path}.lock`)).rejects.toThrow() + }) +}) diff --git a/packages/settings/settings-local/tests/watcher.spec.ts b/packages/settings/settings-local/tests/watcher.spec.ts new file mode 100644 index 0000000000..7b289b22a3 --- /dev/null +++ b/packages/settings/settings-local/tests/watcher.spec.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '../src/index.ts' + +// chokidar is the nondeterministic OS boundary: faking it lets these tests +// drive the event pipeline (error events, races with unreadable files) +// deterministically. Real end-to-end watching stays covered by local.spec.ts. +vi.mock('chokidar', async () => { + const { EventEmitter } = await import('node:events') + class FakeWatcher extends EventEmitter { + close = vi.fn(() => Promise.resolve()) + } + const instances: Array<{ path: string; options: unknown; watcher: InstanceType }> = [] + return { + watch: vi.fn((path: string, options: unknown) => { + const watcher = new FakeWatcher() + instances.push({ path, options, watcher }) + return watcher + }), + __instances: instances, + } +}) + +interface FakeChokidar { + __instances: Array<{ + path: string + options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } } + watcher: import('node:events').EventEmitter + }> +} + +async function fakeInstances(): Promise { + const chokidar = await import('chokidar') as unknown as FakeChokidar + return chokidar.__instances +} + +const ThemeSchema: z<{ theme: string }> = z.object({ + theme: z.string().default('dark'), +}) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + ;(await fakeInstances()).length = 0 +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('watcher pipeline', () => { + it('clamps the write-settle poll interval for a zero debounce', async () => { + const dir = await tempDir() + await boot({ path: join(dir, 'settings.yaml'), debounceMs: 0 }) + const [instance] = await fakeInstances() + expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) + }) + + it('survives a watcher error and keeps publishing later edits', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const [instance] = await fakeInstances() + + instance!.watcher.emit('error', new Error('watch backend failure')) + expect(scope.get()).toEqual({ theme: 'dark' }) + + await writeFile(path, 'ui-theme:\n theme: light\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(() => { + expect(scope.get()).toEqual({ theme: 'light' }) + }) + }) + + it('keeps the last good document when the file turns unreadable at runtime', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + + await chmod(path, 0o000) + cleanups.push(() => chmod(path, 0o600)) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + // The warn-and-keep path is asynchronous; give the serialized refresh a turn. + await new Promise(resolve => setTimeout(resolve, 50)) + expect(scope.get()).toEqual({ theme: 'light' }) + }) + + it('keeps the reload queue alive after an invariant violation escapes a commit', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + let arm = true + ctx.on('settings/updated', () => { + if (!arm) return + throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) + }) + const [instance] = await fakeInstances() + + await writeFile(path, 'ui-theme:\n theme: broken-commit\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(() => { + expect(scope.get().theme).toBe('broken-commit') + }) + + arm = false + await writeFile(path, 'ui-theme:\n theme: recovered\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(() => { + expect(scope.get().theme).toBe('recovered') + }) + }) + + it('quiesces the refresh pipeline before dispose completes', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, { path, debounceMs: 5 }) + await fiber + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + let disposed = false + let postDisposeCommits = 0 + ctx.on('settings/updated', () => { + if (disposed) postDisposeCommits += 1 + }) + + await writeFile(path, 'ui-theme:\n theme: darker\n') + const [instance] = await fakeInstances() + // Two queued refreshes: dispose interrupts one mid-flight and the other + // before it starts, so both closed guards must hold. + instance!.watcher.emit('all', 'change', path) + instance!.watcher.emit('all', 'change', path) + await fiber.dispose() + disposed = true + instance!.watcher.emit('all', 'change', path) + instance!.watcher.emit('ready') + await new Promise(resolve => setTimeout(resolve, 100)) + expect(postDisposeCommits).toBe(0) + }) + + it('treats an event for a still-absent file as a no-op', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'add', path) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(scope.get()).toEqual({ theme: 'dark' }) + }) + + it('folds an unobserved external edit into a write instead of overwriting it', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const editor = ctx.settings.register(settingsNamespace('editor'), z.object({ + tabWidth: z.number().default(2), + })) + // The external edit has landed on disk but its watcher event has not + // fired yet (a debounce window, or a missed event): the write must fold + // it in, not resurrect the stale document. + await writeFile(path, 'ui-theme:\n theme: light\neditor:\n tabWidth: 8\n') + await theme.update({ theme: 'darker' }) + const text = await readFile(path, 'utf8') + expect(text).toContain('tabWidth: 8') + expect(text).toContain('theme: darker') + // The fold published the unobserved section before the write committed. + expect(editor.get()).toEqual({ tabWidth: 8 }) + }) + + it('reconciles at watcher ready so a change during setup is not missed', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + // Written after the initial load but before the watcher became active: + // no 'all' event will ever fire for it. + await writeFile(path, 'ui-theme:\n theme: written-before-ready\n') + const [instance] = await fakeInstances() + instance!.watcher.emit('ready') + await vi.waitFor(() => { + expect(scope.get().theme).toBe('written-before-ready') + }) + }) + + it('fails a write loud when the on-disk document turned invalid unobserved', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const broken = 'ui-theme: [unclosed\n flow: {\n' + await writeFile(path, broken) + await expect(scope.update({ theme: 'darker' })).rejects.toThrow(/invalid document/) + // The user's manual edit stays on disk untouched and the cache keeps the + // last good value. + expect(await readFile(path, 'utf8')).toBe(broken) + expect(scope.get()).toEqual({ theme: 'light' }) + }) +}) diff --git a/packages/settings/settings-local/tsconfig.json b/packages/settings/settings-local/tsconfig.json new file mode 100644 index 0000000000..67a746c982 --- /dev/null +++ b/packages/settings/settings-local/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/paths" + }, + { + "path": "../settings" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml new file mode 100644 index 0000000000..4f03498b06 --- /dev/null +++ b/packages/settings/settings/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/settings/settings/README.md +README.md: ec9f0e09c47015edd8495dac48beb610e0b5cdc5 +README.zh.md: 6d0a760f9b1bbef21881a03933d0fe5b9fc3cd0d diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md new file mode 100644 index 0000000000..ec9f0e09c4 --- /dev/null +++ b/packages/settings/settings/README.md @@ -0,0 +1,37 @@ +# @deepseek-ai/dsh-settings + +English | [中文](README.zh.md) + +Abstract user-settings seam (`ctx.settings`). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition `base` (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings. + +## Service API + +- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. +- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces. +- `get(ns)` — resolved value, `undefined` while unregistered. +- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. +- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults). +- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. After a watch disposer returns, no further invocation starts (one already queued is skipped); an invocation already started still settles. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest; an async listener's rejection is contained and logged, which is why `INVARIANT`-coded failures rethrow only from synchronous listeners. +- Service teardown refuses new writes and watcher starts, then drains every queued write and every started watcher invocation before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody. + +## Provider contract + +Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud. + +## Events + +`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value. + +## Model Experience + +Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect. + +#### KV Cache effect + +No direct invalidation; a consumer that folds a settings value into the request prefix owns that change. + +## Known Limitations and Deferred Work + +- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet. +- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins). +- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure. diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md new file mode 100644 index 0000000000..6d0a760f9b --- /dev/null +++ b/packages/settings/settings/README.zh.md @@ -0,0 +1,37 @@ +# @deepseek-ai/dsh-settings + +[English](README.md) | 中文 + +抽象用户设置 seam(`ctx.settings`)。一个 provider 持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值:schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载 provider 时消费者行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。 + +## 服务 API + +- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。 +- `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。 +- `get(ns)` — 解析值;未注册时为 `undefined`。 +- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 +- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。 +- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。 +- 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。 + +## Provider 契约 + +子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。 + +## 事件 + +`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。 + +## Model Experience + +间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。 + +#### KV Cache effect + +无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。 + +## Known Limitations and Deferred Work + +- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。 +- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。 +- **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。 diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json new file mode 100644 index 0000000000..7586b4bb24 --- /dev/null +++ b/packages/settings/settings/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-settings", + "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "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" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.18.0" + } +} diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts new file mode 100644 index 0000000000..76c1082f3f --- /dev/null +++ b/packages/settings/settings/src/index.ts @@ -0,0 +1,549 @@ +/** + * User-settings seam (`ctx.settings`). Providers store one raw document of + * per-namespace sections; plugins register a namespace schema and read the + * resolved value, which layers schema defaults, the registrant's composition + * `base`, and the user document section, in that order. + * @module @deepseek-ai/dsh-settings + */ + +import { Context, Service } from 'cordis' +import type z from 'schemastery' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Nominal id of one registered settings namespace. */ +export type SettingsNamespace = Branded<'SettingsNamespace'> + +const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/ + +/** + * Brand a raw string as a {@link SettingsNamespace}. + * @param value - candidate namespace; lowercase kebab-case, as in plugin short names. + * @returns the branded namespace. + */ +export function settingsNamespace(value: string): SettingsNamespace { + if (!NAMESPACE_PATTERN.test(value)) { + throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`) + } + return value as SettingsNamespace +} + +/** When a namespace's changes take effect for its owner. */ +export type SettingsApplies = 'live' | 'restart' + +/** Origin of one committed settings change. */ +export type SettingsUpdateSource = 'update' | 'provider' + +/** Registration options beyond the namespace schema. */ +export interface SettingsRegisterOptions { + /** Composition-layer values resolved below the user layer (entry-config subset). */ + base?: Partial + /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ + applies?: SettingsApplies +} + +/** One registered namespace as surfaced to configuration UIs. */ +export interface SettingsDescriptor { + // TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the + // public seam, provider contract, implementations, tests, and consumers. + /** The registered namespace. */ + ns: SettingsNamespace + /** Serialized schemastery schema (`schema.toJSON()`). */ + schema: unknown + /** Current resolved value. */ + value: unknown + /** Owner's declared effect timing. */ + applies: SettingsApplies +} + +/** Owner-facing handle for one registered namespace. */ +export interface SettingsScope { + /** Current resolved value: schema defaults, then `base`, then the user layer. */ + get(): T + /** + * Observe committed changes to this namespace's resolved value. Invocations + * of one callback run asynchronously, one at a time, in commit order; a + * rejection is contained and logged like a sync throw. After the disposer + * returns, no further invocation starts — one already queued is skipped; + * one already started still settles, and service disposal waits for it. + * @param callback - invoked after each commit with the next and previous values. + * @returns the disposer removing this observer. + */ + watch(callback: (next: T, prev: T) => void | Promise): () => void + /** + * Merge a partial patch into this namespace's user layer and persist it. + * @param patch - plain-object patch over the user section; JSON-shaped data + * only (non-JSON values reject with their path before anything persists). + */ + update(patch: object): Promise + /** + * Replace this namespace's user section wholesale; absent keys re-inherit + * the composition `base` and schema defaults (`replace({})` resets all). + * @param section - the complete next user section; JSON-shaped data only, + * as for {@link update}. + */ + replace(section: object): Promise +} + +declare module 'cordis' { + interface Context { + settings: Settings + } + + interface Events { + /** + * Committed change to one registered namespace's resolved value. Emitted + * after the provider persisted (for `update`) or published (`provider`) + * the change; never emitted when the resolved value is deep-equal. + * Listener failures are contained and logged — a sync throw and an async + * rejection alike — except `INVARIANT`-coded failures, which rethrow + * after every listener ran; that rethrow reaches the emitter only from + * synchronous listeners, so invariant checks on this event must not be + * async functions. + * @param ns - the namespace whose resolved value changed. + * @param next - the new resolved value. + * @param prev - the previous resolved value. + * @param source - whether the change entered through `update()` or the provider. + * @mode emit + */ + 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void + } +} + +/** + * Deep equality over JSON-shaped data (objects, arrays, primitives) — the + * seam's single change-detection predicate, exported so the invariant + * companion checks exactly the implementation's relation. + * @param a - one JSON-shaped value. + * @param b - the other JSON-shaped value. + * @returns whether the two values are structurally equal. + */ +export function deepEqualJson(a: unknown, b: unknown): boolean { + if (a === b) return true + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + return a.every((entry, index) => deepEqualJson(entry, b[index])) + } + const left = a as Record + const right = b as Record + const keys = Object.keys(left) + if (keys.length !== Object.keys(right).length) return false + return keys.every(key => key in right && deepEqualJson(left[key], right[key])) +} + +/** Whether a value is a plain data object (not an array, null, or class instance). */ +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const proto: unknown = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */ +function describeRejected(value: unknown): string { + if (value === undefined) return 'undefined' + if (typeof value === 'object' && value !== null) { + const proto = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null + const name = proto?.constructor?.name + return name === undefined || name === 'Object' ? 'a non-plain object' : `a ${name}` + } + return `a ${typeof value}` +} + +/** + * Detach one write input in a single walk that doubles as the durable-boundary + * shape check: only JSON data (plain objects, arrays, strings, finite numbers, + * booleans, `null`) may reach a provider document. `structuredClone` alone + * would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then + * silently distorts on the reload round-trip. `undefined` entries in objects + * are skipped — the same sparse-patch semantics as {@link mergeLayers} — while + * an `undefined` array entry is rejected rather than coerced. + * @param root - plain-object write input (caller-checked). + * @param reject - builds the boundary error from a value label and its `$`-rooted path. + * @returns the detached JSON-shaped clone. + */ +function cloneJsonShaped( + root: Record, + reject: (label: string, path: string) => TypeError, +): Record { + const visiting = new WeakSet() + const clone = (value: unknown, path: string): unknown => { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw reject('a non-finite number', path) + return value + } + if (Array.isArray(value)) { + if (visiting.has(value)) throw reject('a circular reference', path) + visiting.add(value) + const entries = value.map((entry, index) => clone(entry, `${path}[${index}]`)) + // Un-mark on exit so one object referenced twice without a cycle passes. + visiting.delete(value) + return entries + } + if (isPlainObject(value)) { + if (visiting.has(value)) throw reject('a circular reference', path) + visiting.add(value) + // TODO(settings-json-properties): Use property-safe construction here and + // in mergeLayers so valid JSON keys such as "__proto__" remain own data. + const out: Record = {} + for (const [key, entry] of Object.entries(value)) { + if (entry === undefined) continue + out[key] = clone(entry, `${path}.${key}`) + } + visiting.delete(value) + return out + } + throw reject(describeRejected(value), path) + } + return clone(root, '$') as Record +} + +/** + * Layer `over` onto `under`: plain objects merge recursively, every other + * value (arrays included) replaces the lower layer wholesale. `over` never + * carries `undefined` entries — sections come from parsed documents and write + * snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch + * cannot erase lower keys. + */ +function mergeLayers(under: unknown, over: unknown): unknown { + if (over === undefined) return under + if (!isPlainObject(under) || !isPlainObject(over)) return over + const merged: Record = { ...under } + for (const [key, value] of Object.entries(over)) { + merged[key] = key in merged ? mergeLayers(merged[key], value) : value + } + return merged +} + +/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */ +function deepFreeze(value: T): T { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value + for (const entry of Object.values(value)) deepFreeze(entry) + return Object.freeze(value) +} + +/** One registered watcher and its serialized invocation chain. */ +interface SettingsWatcher { + callback: (next: never, prev: never) => void | Promise + /** Settled tail: invocations of this callback run one at a time, in commit order. */ + tail: Promise + /** Cleared by the disposer: a queued invocation checks this before starting. */ + active: boolean +} + +/** One live namespace registration owned by a registrant fiber. */ +interface SettingsRegistration { + ns: SettingsNamespace + schema: z + base: unknown + applies: SettingsApplies + resolved: unknown + watchers: Set +} + +/** + * Abstract settings service. Providers implement raw-document storage + * (`load`/`persist`) and push external changes through {@link Settings.publish}; + * the base class owns namespace registration, resolution, validation, change + * detection, and the `settings/updated` commit event. + */ +export abstract class Settings extends Service { + private readonly registrations = new Map() + /** Latest published raw document; empty until the provider's first publish. */ + private document: Record = {} + /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */ + private readonly writeQueues = new Map>() + /** In-flight watcher invocation segments, drained by the dispose teardown. */ + private readonly pendingTails = new Set>() + /** Set at service dispose: refuse new writes while queued ones drain. */ + private stopped = false + + /** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */ + private isStopped(): boolean { + return this.stopped + } + + constructor(ctx: Context) { + super(ctx, 'settings') + } + + /** + * Load the provider's document once and publish it before the service + * becomes injectable, and register the write-drain teardown. Providers with + * their own init (watchers, connections) delegate here first via + * `yield* super[Service.init]()`; their disposers then run before the drain. + */ + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { + yield async () => { + // Teardown: refuse new writes and new watcher starts, then wait until + // every queued write chain and every started watcher invocation settles + // so disposal completes only once storage and observers are quiescent. + // Invocations queued but not yet started skip via the stopped check. + this.stopped = true + await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails]) + } + this.publish(await this.load()) + } + + /** Whether {@link update} may persist through this provider. */ + abstract readonly writable: boolean + + /** + * Read the provider's current raw document (namespace to raw section). + * @returns the detached raw document. + */ + protected abstract load(): Promise> + + /** + * Durably store one namespace's merged user section. + * @param ns - the namespace being written. + * @param section - the complete merged user section to store. + */ + protected abstract persist(ns: SettingsNamespace, section: Record): Promise + + /** + * Register a namespace schema and receive its owner scope. The registration + * is an effect on the calling plugin's fiber: disposing that fiber removes + * the namespace and its observers. An invalid stored section fails the + * registration itself — the earliest point where the schema can judge it. + * @param ns - unique namespace; duplicate registration fails loud. + * @param schema - schemastery schema resolving this namespace's value. + * @param options - composition `base` layer and effect timing. + * @returns the owner scope for reads, observation, and updates. + */ + register(ns: SettingsNamespace, schema: z, options?: SettingsRegisterOptions): SettingsScope { + if (this.registrations.has(ns)) { + throw new Error(`settings namespace "${ns}" is already registered`) + } + const registration: SettingsRegistration = { + ns, + schema: schema as z, + base: options?.base, + applies: options?.applies ?? 'live', + resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))), + watchers: new Set(), + } + this.ctx.effect(() => { + this.registrations.set(ns, registration) + // TODO(settings-registration-quiescence): Deactivate every watcher and await + // its tail on disposal so callbacks cannot outlive the registrant fiber. + return () => this.registrations.delete(ns) + }, `settings.register(${JSON.stringify(String(ns))})`) + return { + get: () => registration.resolved as T, + watch: (callback) => { + const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve(), active: true } + registration.watchers.add(watcher) + return () => { + watcher.active = false + registration.watchers.delete(watcher) + } + }, + update: patch => this.update(ns, patch), + replace: section => this.replace(ns, section), + } + } + + /** + * Describe every registered namespace for configuration surfaces. + * @returns one descriptor per registered namespace, in registration order. + */ + describe(): SettingsDescriptor[] { + return [...this.registrations.values()].map(registration => ({ + ns: registration.ns, + schema: registration.schema.toJSON(), + value: registration.resolved, + applies: registration.applies, + })) + } + + /** + * Read one registered namespace's resolved value. + * @param ns - the namespace to read. + * @returns the resolved value, or `undefined` while unregistered. + */ + get(ns: SettingsNamespace): unknown { + return this.registrations.get(ns)?.resolved + } + + /** + * Merge a patch into one registered namespace's user layer, validate the + * resolved candidate, persist through the provider, then commit and emit. + * A validation failure rejects before anything is persisted. Writes to one + * namespace are serialized: concurrent updates apply in call order, each + * merging over the previous write's committed section. + * @param ns - the registered namespace to update. + * @param patch - plain-object patch over the user section. + */ + async update(ns: SettingsNamespace, patch: object): Promise { + return this.write(ns, patch, 'merge') + } + + /** + * Replace one registered namespace's user section wholesale, validate, + * persist, then commit and emit. Keys absent from `section` fall back to the + * composition `base` and schema defaults — this is the removal/reset path a + * merge-only patch cannot express (`replace({})` re-inherits everything). + * @param ns - the registered namespace to replace. + * @param section - the complete next user section. + */ + async replace(ns: SettingsNamespace, section: object): Promise { + return this.write(ns, section, 'replace') + } + + /** Validate a write, then queue it on the namespace's serialized write chain. */ + private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise { + const verb = mode === 'merge' ? 'update' : 'replace' + const registration = this.registrations.get(ns) + if (registration === undefined) { + throw new Error(`settings namespace "${ns}" is not registered`) + } + if (this.isStopped()) { + throw new Error(`settings service is disposed: "${ns}" cannot be written`) + } + if (!this.writable) { + throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`) + } + if (!isPlainObject(input)) { + throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`) + } + // Snapshot at call time: the queue must never read a caller-owned object + // the caller may keep mutating while the write waits its turn. The same + // walk is the JSON-shape boundary check (see cloneJsonShaped). + const snapshot = cloneJsonShaped(input, (label, path) => + new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`)) + const previous = this.writeQueues.get(ns) ?? Promise.resolve() + // Chain past a failed predecessor: one rejected write must not poison the + // namespace queue for every later caller. + const run = previous.catch(() => undefined).then(async () => { + if (this.isStopped()) { + throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`) + } + if (this.registrations.get(ns) !== registration) { + throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`) + } + const section = mode === 'merge' + ? mergeLayers(this.section(ns) ?? {}, snapshot) as Record + : snapshot + const next = deepFreeze(this.resolve(registration.schema, registration.base, section)) + await this.persist(ns, section) + // The write reached storage either way; the cache must say so. Commit + // only when this registration is still the namespace owner — a fiber + // disposed (or replaced) mid-persist must not receive the notification. + this.document[ns] = section + // TODO(settings-replacement-resync): Re-resolve any replacement registration + // from this persisted section so an old in-flight write cannot leave it stale. + if (this.registrations.get(ns) === registration && !this.isStopped()) { + this.commit(registration, next, 'update') + } + }) + this.writeQueues.set(ns, run) + return run + } + + /** + * Provider hook: commit a complete raw document observed in storage. Each + * registered namespace re-resolves; an invalid section keeps that + * namespace's last good value and warns, other namespaces still commit. + * @param doc - the detached raw document (unregistered sections preserved). + * @param source - change origin; defaults to `provider`. + */ + protected publish(doc: Record, source: SettingsUpdateSource = 'provider'): void { + this.document = doc + for (const registration of this.registrations.values()) { + let next: unknown + try { + next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns))) + } catch (error) { + this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns) + this.ctx.logger.warn(error) + continue + } + this.commit(registration, next, source) + } + } + + /** Read one namespace's raw user section, rejecting non-object sections. */ + private section(ns: SettingsNamespace): Record | undefined { + const section = this.document[ns] + if (section === undefined) return undefined + if (!isPlainObject(section)) { + throw new TypeError(`settings section "${ns}" must be an object of keys`) + } + return section + } + + /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */ + private resolve(schema: z, base: unknown, section: Record | undefined): T { + // The merged candidate is untyped by construction; the schema call is the + // runtime validation that admits it into T. + return schema(mergeLayers(base, section) as never) + } + + /** Commit a resolved value when changed: swap, notify watchers, emit the event. */ + private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void { + const prev = registration.resolved + if (deepEqualJson(next, prev)) return + registration.resolved = next + for (const watcher of [...registration.watchers]) { + // Serialize per watcher: invocations of one callback run one at a time + // in commit order, so a slow stale invocation can never apply after a + // newer one. Sync throws and async rejections land in the same handler. + // The activity check runs when the queued invocation would start, so a + // disposer (or service stop) that ran while it waited prevents the + // start entirely; started invocations drain at service dispose. + const segment = watcher.tail + .then(() => { + if (!watcher.active || this.isStopped()) return + return watcher.callback(next as never, prev as never) + }) + .then(() => undefined, (error: unknown) => { + this.warnWatcherFailure(registration.ns, error) + }) + watcher.tail = segment + this.pendingTails.add(segment) + void segment.then(() => this.pendingTails.delete(segment)) + } + // Fan the event out one listener at a time (the plain emit stops at the + // first throwing listener, starving the rest). Invariant violations are + // harness-fatal by design and rethrow after every listener ran; any other + // failure is contained so one broken observer cannot wedge the commit + // path (and, through it, a provider's reload loop). + let invariantFailure: unknown + const args = ['settings/updated', registration.ns, next, prev, source] + for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { + try { + const returned = listener(registration.ns, next, prev, source) + if (returned != null && typeof (returned as PromiseLike).then === 'function') { + // An emit listener may still be an async function; its rejection + // cannot reach the synchronous INVARIANT rethrow below, so it is + // contained here instead of becoming an unhandled rejection. + void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { + this.warnListenerFailure(registration.ns, error) + }) + } + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { + invariantFailure ??= error + continue + } + this.warnListenerFailure(registration.ns, error) + } + } + if (invariantFailure !== undefined) throw invariantFailure as Error + } + + /** Contained-watcher diagnostic shared by the sync and async failure paths. */ + private warnWatcherFailure(ns: SettingsNamespace, error: unknown): void { + this.ctx.logger.warn('settings: watcher for "%s" failed', ns) + this.ctx.logger.warn(error) + } + + /** Contained-listener diagnostic shared by the sync and async failure paths. */ + private warnListenerFailure(ns: SettingsNamespace, error: unknown): void { + this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', ns) + this.ctx.logger.warn(error) + } +} + +export default Settings diff --git a/packages/settings/settings/src/invariant.ts b/packages/settings/settings/src/invariant.ts new file mode 100644 index 0000000000..d8db41bce4 --- /dev/null +++ b/packages/settings/settings/src/invariant.ts @@ -0,0 +1,48 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-settings`. + * @module @deepseek-ai/dsh-settings/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { deepEqualJson } from './index.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-settings' + +/** Cordis companion plugin name. */ +export const name = 'settings-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Install the commit-event contract: `settings/updated` fires only for a + * currently registered namespace, only when the resolved value changed, and + * only with the service's authoritative resolved value — all judged with the + * seam's own equality predicate. + */ +const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { + ctx.on('settings/updated', (ns, next, prev) => { + const settings = ctx.get('settings') + if (settings === undefined) { + fail(`settings/updated for "${ns}" emitted without a live settings service`) + } + const current = settings.get(ns) + if (current === undefined) { + fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`) + } + if (!deepEqualJson(current, next)) { + fail(`settings/updated for "${ns}" does not match the authoritative resolved value`) + } + if (deepEqualJson(next, prev)) { + fail(`settings/updated for "${ns}" emitted without a resolved-value change`) + } + }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/settings/settings/tests/invariant.spec.ts b/packages/settings/settings/tests/invariant.spec.ts new file mode 100644 index 0000000000..0976827368 --- /dev/null +++ b/packages/settings/settings/tests/invariant.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SettingsInvariant from '../src/invariant.ts' +import { settingsNamespace } from '../src/index.ts' +import { MemorySettings } from './memory.ts' + +async function setup(withProvider: boolean): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(SettingsInvariant) + if (withProvider) await ctx.plugin(MemorySettings) + return ctx +} + +describe('settings invariants', () => { + it('fails a settings/updated emission without a live settings service', async () => { + const ctx = await setup(false) + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider') + }).toThrow(/without a live settings service/) + }) + + it('fails a settings/updated emission for an unregistered namespace', async () => { + const ctx = await setup(true) + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider') + }).toThrow(/unregistered/) + }) + + it('fails a settings/updated emission without a resolved-value change', async () => { + const ctx = await setup(true) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + theme: z.string().default('dark'), + })) + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update') + }).toThrow(/without a resolved-value change/) + }) + + it('fails a settings/updated emission whose value diverges from the authoritative state', async () => { + const ctx = await setup(true) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + theme: z.string().default('dark'), + })) + // Fabricated next ≠ the service's current resolved value ({theme: 'dark'}). + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'forged' }, { theme: 'dark' }, 'update') + }).toThrow(/authoritative/) + }) +}) diff --git a/packages/settings/settings/tests/memory.ts b/packages/settings/settings/tests/memory.ts new file mode 100644 index 0000000000..bb310c939d --- /dev/null +++ b/packages/settings/settings/tests/memory.ts @@ -0,0 +1,54 @@ +/** + * In-memory settings provider fixture: the smallest real subclass of the seam, + * used by the base-class behavior suite in place of a file- or network-backed + * provider. Kept in `tests/` because production providers live in their own + * packages. + */ + +import { Settings, type SettingsNamespace } from '../src/index.ts' + +/** In-memory provider exposing the protected seam hooks to tests. */ +export class MemorySettings extends Settings { + /** Raw document the provider "storage" currently holds. */ + doc: Record + /** Every persist() call observed, in order. */ + persisted: Array<{ ns: SettingsNamespace; section: Record }> = [] + /** When false, update() must reject before reaching persist(). */ + writableFlag: boolean + + /** Artificial persist latency so tests can interleave concurrent updates. */ + persistDelayMs: number + + constructor(ctx: ConstructorParameters[0], options?: { + doc?: Record + writable?: boolean + persistDelayMs?: number + }) { + super(ctx) + this.doc = structuredClone(options?.doc ?? {}) + this.writableFlag = options?.writable ?? true + this.persistDelayMs = options?.persistDelayMs ?? 0 + } + + get writable(): boolean { + return this.writableFlag + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected async persist(ns: SettingsNamespace, section: Record): Promise { + if (this.persistDelayMs > 0) { + await new Promise(resolve => setTimeout(resolve, this.persistDelayMs)) + } + this.persisted.push({ ns, section: structuredClone(section) }) + this.doc[ns] = structuredClone(section) + } + + /** Simulate an external storage change reaching the provider. */ + pushExternal(doc: Record): void { + this.doc = structuredClone(doc) + this.publish(structuredClone(doc)) + } +} diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts new file mode 100644 index 0000000000..379ce02d08 --- /dev/null +++ b/packages/settings/settings/tests/settings.spec.ts @@ -0,0 +1,654 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' +import { MemorySettings } from './memory.ts' + +/** A provider implementing only the three primitives: the seam owns init. */ +class BareProvider extends Settings { + doc: Record + + constructor(ctx: ConstructorParameters[0], options?: { doc?: Record }) { + super(ctx) + this.doc = structuredClone(options?.doc ?? {}) + } + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc[ns] = structuredClone(section) + return Promise.resolve() + } +} + +interface ThemeConfig { + theme: 'dark' | 'light' + fontSize: number +} + +const ThemeSchema: z = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +interface NestedConfig { + retry: { attempts: number; delayMs: number } + tags: string[] +} + +const NestedSchema: z = z.object({ + retry: z.object({ + attempts: z.number().default(2), + delayMs: z.number().default(100), + }), + tags: z.array(z.string()).default(['default']), +}) + +async function boot(options?: ConstructorParameters[1]) { + const ctx = new Context() + const fiber = ctx.plugin(MemorySettings, options) + await fiber + const provider = ctx.get('settings') as MemorySettings + return { ctx, provider, fiber } +} + +/** Record every settings/updated emission. */ +function recordUpdates(ctx: Context) { + const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = [] + ctx.on('settings/updated', (ns, next, prev, source) => { + events.push({ ns, next, prev, source }) + }) + return events +} + +describe('settingsNamespace', () => { + it('brands lowercase kebab-case names', () => { + expect(settingsNamespace('ui-theme')).toBe('ui-theme') + }) + + it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => { + expect(() => settingsNamespace(value)).toThrow(TypeError) + }) +}) + +describe('registration', () => { + it('resolves schema defaults, then composition base, then the user layer', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + // theme: user layer wins; fontSize: base wins over the schema default. + expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) + }) + + it('rejects a duplicate namespace loud', async () => { + const { ctx } = await boot() + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)) + .toThrow(/already registered/) + }) + + it('fails registration when the stored section is invalid for the schema', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } }) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow() + }) + + it('fails registration when the stored section is not an object', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } }) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)) + .toThrow(/must be an object/) + }) + + it('describes registered namespaces with schema JSON, value, and applies', async () => { + const { ctx } = await boot() + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' }) + const descriptors = ctx.settings.describe() + expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([ + ['ui-theme', 'live'], + ['workspace', 'restart'], + ]) + expect(descriptors[0]!.value).toEqual({ theme: 'dark', fontSize: 14 }) + // schemastery's canonical wire form: a { uid, refs } envelope whose root ref + // is the object schema — the shape schema-driven form UIs reconstruct from. + const serialized = descriptors[0]!.schema as { uid: number; refs: Record } + expect(serialized.refs[String(serialized.uid)]?.type).toBe('object') + }) + + it('reads undefined for an unregistered namespace', async () => { + const { ctx } = await boot() + expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined() + }) + + it('hands out frozen resolved values', async () => { + const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } }) + const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema) + const value = scope.get() + expect(Object.isFrozen(value)).toBe(true) + expect(Object.isFrozen(value.retry)).toBe(true) + expect(() => { (value.retry as { attempts: number }).attempts = 0 }).toThrow(TypeError) + }) + + it('removes the namespace and its observers when the registrant fiber disposes', async () => { + const { ctx, provider } = await boot() + const seen: unknown[] = [] + let scope: SettingsScope | undefined + const fiber = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch((next) => { seen.push(next) }) + }, + }) + await fiber + expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 }) + + await fiber.dispose() + expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined() + expect(ctx.settings.describe()).toEqual([]) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(seen).toEqual([]) + + // The namespace is free again, and re-registration resolves the user layer + // that kept living in storage while nobody owned the namespace. + const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(again.get()).toEqual({ theme: 'light', fontSize: 14 }) + }) +}) + +describe('update', () => { + it('persists the merged user section without baking in the base layer', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + await scope.update({ theme: 'dark' }) + expect(provider.persisted).toEqual([ + { ns: 'ui-theme', section: { theme: 'dark' } }, + ]) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) + }) + + it('deep-merges nested objects and replaces arrays wholesale', async () => { + const { ctx, provider } = await boot({ + doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } }, + }) + const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema) + await scope.update({ retry: { attempts: 7 }, tags: ['c'] }) + expect(provider.persisted[0]!.section).toEqual({ + retry: { attempts: 7, delayMs: 300 }, + tags: ['c'], + }) + expect(scope.get()).toEqual({ retry: { attempts: 7, delayMs: 300 }, tags: ['c'] }) + }) + + it('commits, notifies watchers, and emits with source update', async () => { + const { ctx } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + scope.watch(watcher) + await scope.update({ theme: 'light' }) + expect(watcher).toHaveBeenCalledWith( + { theme: 'light', fontSize: 14 }, + { theme: 'dark', fontSize: 14 }, + ) + expect(events).toEqual([{ + ns: 'ui-theme', + next: { theme: 'light', fontSize: 14 }, + prev: { theme: 'dark', fontSize: 14 }, + source: 'update', + }]) + }) + + it('rejects an invalid patch before persisting anything', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update({ fontSize: 'big' })).rejects.toThrow() + expect(provider.persisted).toEqual([]) + expect(events).toEqual([]) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + // The failed write must not poison the namespace queue for later writers. + await scope.update({ fontSize: 18 }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) + }) + + it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: undefined, fontSize: 18 }) + expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 }) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 }) + }) + + it('rejects a non-object patch', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update([1])).rejects.toThrow(TypeError) + await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError) + await expect(scope.replace([1])).rejects.toThrow(/replace for "ui-theme"/) + }) + + it('accepts a null-prototype patch object', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number } + patch.fontSize = 18 + await scope.update(patch) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) + }) + + it('rejects an unregistered namespace', async () => { + const { ctx } = await boot() + await expect(ctx.settings.update(settingsNamespace('missing'), {})) + .rejects.toThrow(/not registered/) + }) + + it('rejects on a read-only provider before reaching persist', async () => { + const { ctx, provider } = await boot({ writable: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/) + expect(provider.persisted).toEqual([]) + }) +}) + +describe('deepEqualJson', () => { + it.each([ + [{ a: [1, 2] }, { a: [1, 2] }, true], + [{ a: [1, 2] }, { a: [1] }, false], + [{ a: [1] }, { a: { 0: 1 } }, false], + [{ a: 1 }, { b: 1 }, false], + [{ a: 1 }, {}, false], + [{ a: null }, { a: null }, true], + [{ a: null }, { a: {} }, false], + ])('compares %j vs %j as %s', (a, b, equal) => { + expect(deepEqualJson(a, b)).toBe(equal) + }) +}) + +describe('review regressions', () => { + it('propagates an invariant-coded listener failure instead of containing it', async () => { + const { ctx, provider } = await boot() + ctx.on('settings/updated', () => { + throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) + }) + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }) + .toThrow(/forged relation/) + }) + + it('serializes concurrent updates so neither patch is lost', async () => { + const { ctx, provider } = await boot({ persistDelayMs: 10 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await Promise.all([ + scope.update({ theme: 'light' }), + scope.update({ fontSize: 20 }), + ]) + expect(provider.doc['ui-theme']).toEqual({ theme: 'light', fontSize: 20 }) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 20 }) + }) + + it('contains a throwing settings/updated listener and keeps later commits alive', async () => { + const { ctx, provider } = await boot() + ctx.on('settings/updated', () => { + throw new Error('listener boom') + }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }).not.toThrow() + expect(scope.get().theme).toBe('light') + provider.pushExternal({ 'ui-theme': { theme: 'dark' } }) + expect(scope.get().theme).toBe('dark') + }) + + it('contains an async watcher rejection', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch(async () => { + throw new Error('async watcher boom') + }) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(scope.get().theme).toBe('light') + // Give the rejected watcher promise a microtask turn; containment means + // vitest observes no unhandled rejection out of this test. + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + it('loads the provider document through the base init without provider boilerplate', async () => { + const ctx = new Context() + await ctx.plugin(BareProvider, { doc: { 'ui-theme': { fontSize: 7 } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 7 }) + }) + + it('replaces the user section wholesale so overrides can be removed', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light', fontSize: 20 } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + await scope.replace({ theme: 'light' }) + // fontSize override is gone: resolution falls back to the base layer. + expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) + expect(provider.doc['ui-theme']).toEqual({ theme: 'light' }) + await scope.replace({}) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) + expect(provider.doc['ui-theme']).toEqual({}) + }) +}) + +describe('second review regressions', () => { + it('runs every settings/updated listener even when an earlier one throws', async () => { + const { ctx, provider } = await boot() + ctx.on('settings/updated', () => { + throw new Error('first listener boom') + }) + const second = vi.fn() + ctx.on('settings/updated', second) + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(second).toHaveBeenCalledTimes(1) + }) + + it('rejects an update queued after the registrant fiber disposed', async () => { + const { ctx } = await boot() + let scope: SettingsScope | undefined + const fiber = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + }, + }) + await fiber + await fiber.dispose() + await expect(scope!.update({ theme: 'light' })).rejects.toThrow(/disposed|not registered/) + }) + + it('does not notify a registrant disposed while its update was in flight', async () => { + const { ctx, provider } = await boot({ persistDelayMs: 30 }) + const events = recordUpdates(ctx) + let scope: SettingsScope | undefined + const watcher = vi.fn() + const fiber = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch(watcher) + }, + }) + await fiber + const pending = scope!.update({ theme: 'light' }) + await new Promise(resolve => setTimeout(resolve, 5)) + await fiber.dispose() + await pending.catch(() => undefined) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(watcher).not.toHaveBeenCalled() + expect(events).toEqual([]) + // The persist was already in flight, so storage keeps the write — but no + // commit reached the disposed registration. + expect(provider.doc['ui-theme']).toEqual({ theme: 'light' }) + }) + + it('drains in-flight writes at service dispose and rejects later ones', async () => { + const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 }) + const service = ctx.settings + const scope = service.register(settingsNamespace('ui-theme'), ThemeSchema) + const pending = scope.update({ theme: 'light' }) + await new Promise(resolve => setTimeout(resolve, 5)) + await fiber.dispose() + // The teardown drained the in-flight write before completing… + await pending.catch(() => undefined) + const persistedAtDispose = provider.persisted.length + expect(persistedAtDispose).toBe(1) + // …and afterwards nothing writes and new writes reject. + await expect(service.update(settingsNamespace('ui-theme'), { theme: 'dark' })) + .rejects.toThrow(/disposed|not registered/) + await new Promise(resolve => setTimeout(resolve, 40)) + expect(provider.persisted.length).toBe(persistedAtDispose) + }) + + it('serializes invocations of one async watcher in commit order', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const applied: number[] = [] + let firstCall = true + scope.watch(async (next) => { + // The first (stale) invocation is slow; unserialised it would finish + // last and clobber the newer applied state. + const delay = firstCall ? 30 : 0 + firstCall = false + await new Promise(resolve => setTimeout(resolve, delay)) + applied.push(next.fontSize) + }) + provider.pushExternal({ 'ui-theme': { fontSize: 1 } }) + provider.pushExternal({ 'ui-theme': { fontSize: 2 } }) + await vi.waitFor(() => { + expect(applied).toHaveLength(2) + }) + expect(applied).toEqual([1, 2]) + }) + + it('rejects a function value as not JSON-shaped', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update({ theme: () => 'dark' })) + .rejects.toThrow(/JSON-shaped.*function at \$\.theme/) + }) + + it('rejects a write still queued when the service disposes', async () => { + const { ctx, fiber } = await boot({ persistDelayMs: 20 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const first = scope.update({ theme: 'light' }) + const second = scope.update({ fontSize: 20 }) + await new Promise(resolve => setTimeout(resolve, 5)) + await fiber.dispose() + await first + await expect(second).rejects.toThrow(/disposed before the queued/) + }) + + it('rejects a write still queued when the registrant disposes', async () => { + const { ctx } = await boot({ persistDelayMs: 20 }) + let scope: SettingsScope | undefined + const fiber = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + }, + }) + await fiber + const first = scope!.update({ theme: 'light' }) + const second = scope!.update({ fontSize: 20 }) + await new Promise(resolve => setTimeout(resolve, 5)) + await fiber.dispose() + await first + await expect(second).rejects.toThrow(/registration was disposed before the queued/) + }) + + it('snapshots the patch at call time so caller mutation cannot leak in', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const patch = { fontSize: 18 } + const pending = scope.update(patch) + patch.fontSize = 99 + await pending + expect(scope.get().fontSize).toBe(18) + }) +}) + +describe('publish', () => { + it('notifies watchers of an external change with source provider', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + scope.watch(watcher) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + await vi.waitFor(() => { + expect(watcher).toHaveBeenCalledWith( + { theme: 'light', fontSize: 14 }, + { theme: 'dark', fontSize: 14 }, + ) + }) + expect(events[0]!.source).toBe('provider') + }) + + it('stays silent when the resolved value is deep-equal', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + scope.watch(watcher) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(watcher).not.toHaveBeenCalled() + expect(events).toEqual([]) + }) + + it('keeps the last good value for an invalid section while other namespaces commit', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema) + provider.pushExternal({ + 'ui-theme': { fontSize: 'broken' }, + workspace: { retry: { attempts: 9 } }, + }) + expect(theme.get()).toEqual({ theme: 'dark', fontSize: 14 }) + expect(workspace.get()).toEqual({ retry: { attempts: 9, delayMs: 100 }, tags: ['default'] }) + expect(events.map(event => event.ns)).toEqual(['workspace']) + }) + + it('recovers from a bad section once storage turns valid again', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + provider.pushExternal({ 'ui-theme': { fontSize: 18 } }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) + }) +}) + +describe('third review regressions', () => { + it('skips a queued watch invocation whose disposer ran before it started', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + const dispose = scope.watch(watcher) + // The commit chains the invocation as a microtask; the disposer runs in + // the same synchronous frame, before that invocation could start. + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + dispose() + await new Promise(resolve => setTimeout(resolve, 10)) + expect(watcher).not.toHaveBeenCalled() + }) + + it('waits for an in-flight watch invocation at service dispose', async () => { + const { ctx, provider, fiber } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + let release: (() => void) | undefined + let finished = false + scope.watch(async () => { + await new Promise((resolve) => { release = resolve }) + finished = true + }) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + await vi.waitFor(() => { expect(release).toBeDefined() }) + let disposed = false + const disposal = fiber.dispose().then(() => { disposed = true }) + await new Promise(resolve => setTimeout(resolve, 15)) + expect(disposed).toBe(false) + release!() + await disposal + expect(finished).toBe(true) + }) + + it('rejects a Date at its path before anything persists', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) + await expect(scope.update({ value: { at: new Date(0) } })) + .rejects.toThrow(/JSON-shaped.*Date at \$\.value\.at/) + expect(provider.persisted).toEqual([]) + }) + + it.each([ + ['a Map', { value: new Map() }, /Map at \$\.value/], + ['a bigint', { value: [10n] }, /bigint at \$\.value\[0\]/], + ['a symbol', { value: Symbol('x') }, /symbol at \$\.value/], + ['a non-finite number', { value: Number.NaN }, /non-finite number at \$\.value/], + ['an undefined array entry', { value: [undefined] }, /undefined at \$\.value\[0\]/], + ['a class instance', { value: Object.create({ marker: true }) as object }, /non-plain object at \$\.value/], + ])('rejects %s that structuredClone would admit', async (_label, patch, message) => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) + await expect(scope.update(patch)).rejects.toThrow(message) + }) + + it('rejects a circular patch instead of storing an alias-looped document', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) + const cyclic: Record = {} + cyclic['self'] = cyclic + await expect(scope.update({ value: cyclic })).rejects.toThrow(/circular reference at \$\.value\.self/) + const loop: unknown[] = [] + loop.push(loop) + await expect(scope.update({ value: loop })).rejects.toThrow(/circular reference at \$\.value\[0\]/) + }) + + it('accepts one object referenced twice without a cycle', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) + const shared = { leaf: 1 } + await scope.update({ value: { left: shared, right: shared } }) + expect(scope.get()).toEqual({ value: { left: { leaf: 1 }, right: { leaf: 1 } } }) + }) + + it('contains an async settings/updated listener rejection and keeps other listeners running', async () => { + const { ctx, provider } = await boot() + // An async listener violates the event's synchronous signature, but an + // unlinted JS plugin can still register one. Declaring the return as + // unknown keeps this file's typed surface legal (unknown-returning + // functions are assignable to void positions) while the runtime value is + // still the rejected promise the containment guard must handle. + const boom = (): unknown => Promise.reject(new Error('async listener boom')) + ctx.on('settings/updated', boom) + const second = vi.fn() + ctx.on('settings/updated', second) + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(second).toHaveBeenCalledTimes(1) + // Containment gives the rejection a handler; vitest observes no unhandled + // rejection out of this test. + await new Promise(resolve => setTimeout(resolve, 10)) + }) +}) + +describe('watch', () => { + it('stops after its disposer runs', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + const dispose = scope.watch(watcher) + dispose() + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(watcher).not.toHaveBeenCalled() + }) + + it('contains a throwing watcher without blocking the commit or other watchers', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch(() => { throw new Error('watcher boom') }) + const second = vi.fn() + scope.watch(second) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + await vi.waitFor(() => { + expect(second).toHaveBeenCalledTimes(1) + }) + expect(events).toHaveLength(1) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) + }) +}) diff --git a/packages/settings/settings/tsconfig.json b/packages/settings/settings/tsconfig.json new file mode 100644 index 0000000000..dd18d27fc3 --- /dev/null +++ b/packages/settings/settings/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 1f545d9673..e0e04a4fa1 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -131,6 +131,26 @@ interface FaceProgramHost { readonly files: Map } +/** + * Process-wide parse cache for the bundled TypeScript default libraries. + * `typescript/lib/lib.*.d.ts` content is immutable for the process lifetime, + * so parses are shared across every {@link WorkspaceCaches} instance; the key + * carries the parse-affecting settings, keeping reuse exact. + */ +const defaultLibraryParses = new Map() + +function defaultLibraryKey(fileName: string, languageVersionOrOptions: ts.ScriptTarget | ts.CreateSourceFileOptions): string { + const options = typeof languageVersionOrOptions === 'object' + ? languageVersionOrOptions + : { languageVersion: languageVersionOrOptions } + return [ + fileName, + String(options.languageVersion), + String(options.impliedNodeFormat ?? ''), + String(options.jsDocParsingMode ?? ''), + ].join('\0') +} + /** * Shared memo over one immutable workspace snapshot. Passing one instance to * several analyzers (the batched and write-mode children reuse their parent's @@ -185,6 +205,13 @@ export class WorkspaceCaches { // only fires under oldProgram reuse, which these fresh programs never // request, and invalidate() is the one supported re-read path. host.getSourceFile = (fileName, languageVersionOrOptions, onError) => { + if (isStandardLibraryFile(fileName)) { + const key = defaultLibraryKey(fileName, languageVersionOrOptions) + if (!defaultLibraryParses.has(key)) { + defaultLibraryParses.set(key, base(fileName, languageVersionOrOptions, onError)) + } + return defaultLibraryParses.get(key) + } if (!files.has(fileName)) files.set(fileName, base(fileName, languageVersionOrOptions, onError)) return files.get(fileName) } diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index c143ec7cfa..e94be1a857 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: dc8af7796d62ca1588423dc63aa592fd3308d218 -README.zh.md: 8e5fb632d8903c1915015396af20a791a9a3ab70 +README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e +README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index dc8af7796d..63c888b1d5 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects ` After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 8e5fb632d8..ca5efc9ae2 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 -TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。 +TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index ca12063262..c3506ea338 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", @@ -74,6 +75,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", diff --git a/packages/ui/tui/src/chat/helpers.ts b/packages/ui/tui/src/chat/helpers.ts index d004500ba7..d96203ecd3 100644 --- a/packages/ui/tui/src/chat/helpers.ts +++ b/packages/ui/tui/src/chat/helpers.ts @@ -1,6 +1,6 @@ /** * Zero-state helpers for the interactive chat channel: prompt-directory and - * Git-branch formatting, surface/tool-call derivations over the session log, + * Git-branch formatting, transcript/tool-call derivations over the session log, * session-reference context cards, the placeholder editor, and banner-reveal * timing constants. None of these close over channel state. * @module @deepseek-ai/dsh-tui/chat/helpers @@ -15,7 +15,9 @@ import { truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui' -import type { Session } from '@deepseek-ai/dsh-session' +import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact' +import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' /** Editor that shows a placeholder without making it editable content. */ @@ -81,24 +83,16 @@ export function gitBranch(cwd: string): string | undefined { } /** - * Sequence numbers currently visible on the session surface. - * @param session - session whose surface nodes to read. - * @returns the set of visible event sequence numbers. - */ -export function activeSurfaceSeqs(session: Session): Set { - return new Set(session.surface.nodes) -} - -/** - * Tool-call ids whose owning assistant message is on the active surface. + * Tool-call ids whose owning assistant message is append-origin, so its tool + * cards stay paired in the transcript after a replacement shadowed the message + * on the model surface. * @param session - session whose events to scan. - * @param active - sequence numbers currently on the surface. - * @returns the set of active tool-call ids. + * @returns the set of transcript tool-call ids. */ -export function activeToolCallIds(session: Session, active: ReadonlySet): Set { +export function transcriptToolCallIds(session: Session): Set { const ids = new Set() for (const event of session.events) { - if (event.type !== 'assistant/message' || !active.has(event.seq)) continue + if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) continue for (const block of event.data.message.content) { if (block.type === 'tool-call') ids.add(block.id) } @@ -106,6 +100,26 @@ export function activeToolCallIds(session: Session, active: ReadonlySet) return ids } +/** + * Whether an event is a landed compaction checkpoint. Recognition goes through + * {@link isCompactCheckpointSource} — the compaction seam's backend-independent + * contract for the source every backend stamps on its replacement user message — + * rather than the shape of the replacement. Other replacements (a pruned + * `tool/result`, a regenerated `assistant/message`) rewrite one node for the + * model and mark no boundary in the conversation. + * + * Both current call sites already test the replacement themselves. The check + * keeps the exported predicate true to its name for a third caller, rather than + * making that caller repeat it. + * @param event - event to test. + * @returns true when the event compacted a surface range. + */ +export function isCompactCheckpoint(event: SessionEvent): boolean { + return event.type === 'user/message' + && isCompactCheckpointSource(event.data.source) + && isReplacementSurfaceEvent(event) +} + /** * Read a session-reference context card's display labels from an event source. * @param source - event source to inspect. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index e1c9c53821..a1250bb5b3 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -35,6 +35,7 @@ import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { + isReplacementSurfaceEvent, lastActivityTime, SessionId, type SessionEvent, @@ -120,14 +121,14 @@ import { } from './chat/skill-invocation.ts' import { ReferenceAutocompleteProvider } from './chat/autocomplete.ts' import { - activeSurfaceSeqs, - activeToolCallIds, BANNER_REVEAL_INTERVAL_MS, BANNER_REVEAL_STEPS, formatCwd, gitBranch, HintEditor, + isCompactCheckpoint, sessionReferenceCard, + transcriptToolCallIds, } from './chat/helpers.ts' import { createModelController, @@ -261,6 +262,13 @@ export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'too /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' +/** + * Transcript row standing in for one compacted range. The conversation the + * compaction replaced stays rendered above it: the marker reports where the + * model stopped seeing that history, not that the history is gone. + */ +const COMPACTION_MARKER = '… earlier context was compacted …' + interface RunningStatus { turn: number | undefined timer: ReturnType @@ -808,6 +816,23 @@ export function createTuiChat( } } + const renderCompactionMarker = (): void => { + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.dim(COMPACTION_MARKER), 0, 0)) + } + + /** + * Replay the human transcript from the append-only log. The model-visible + * surface shadows compacted ranges, so it is not the source here: every + * append-origin message stays rendered, and a replacement contributes at most + * the compaction marker at its own log position. + * + * The `tool/call` pairing check has no live counterpart, because only replay + * can meet an orphan: `tool/call` carries no `surfaceOp` of its own, so it + * inherits transcript membership from the `assistant/message` that advertised + * it, which the live listener has necessarily just rendered. A loaded log is a + * replay boundary, so the pairing is re-derived here instead of assumed. + */ const rebuildTranscript = (populateHistory: boolean): void => { chat.clear() toolCards.clear() @@ -815,15 +840,13 @@ export function createTuiChat( contextCards.clear() streaming = undefined todo.update([]) - const active = activeSurfaceSeqs(agent.session) - const activeCalls = activeToolCallIds(agent.session, active) + const transcriptCalls = transcriptToolCallIds(agent.session) for (const event of agent.session.events) { - const isSurface = event.type === 'user/message' - || event.type === 'assistant/message' - || event.type === 'tool/result' - || event.type === 'steering/message' - if (isSurface && !active.has(event.seq)) continue - if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue + if (isReplacementSurfaceEvent(event)) { + if (isCompactCheckpoint(event)) renderCompactionMarker() + continue + } + if (event.type === 'tool/call' && !transcriptCalls.has(event.data.callId)) continue renderEvent(event, { addHistory: populateHistory, renderChunks: false }) } requestRender() @@ -1475,8 +1498,11 @@ export function createTuiChat( recordEventUsage(tokens, event) if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined - if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { - rebuildTranscript(false) + // A replacement mutates only the model surface, so the rendered transcript + // keeps what it already showed; a landed summary checkpoint adds its marker. + if (isReplacementSurfaceEvent(event)) { + if (isCompactCheckpoint(event)) renderCompactionMarker() + requestRender() return } renderEvent(event, { addHistory: false, renderChunks: true }) diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt index e1686f3574..e071335d14 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt @@ -1,7 +1,7 @@ -terminal 44x18 buffer=normal length=18 base=0 viewport=0 +terminal 44x18 buffer=normal length=24 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=14 bufferRow=14 +cursor hidden column=7 viewportRow=17 bufferRow=23 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -13,25 +13,39 @@ buffer 3| 4| "Assistant " style 0-8 fg=bright-magenta bold underline -5| "Model wait 0.0s " +5| +6| "You " + style 0-2 fg=bright-magenta bold underline +7| "Old prompt with a long line that exercises " +8| "wrapping and stays visible after compaction." +9| +10| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +11| "$ pnpm run test:coverage " + style 0-23 dim +12| "/workspace/project " + style 0-17 dim +13| "packages/ui/tui 100% " + style 0-19 dim +14| "… +1 lines (Ctrl+O to expand) " + style 0-28 dim +15| "1 test skipped " + style 0-13 dim +16| "coverage complete " + style 0-16 dim +17| "[exit 0] " + style 0-7 dim +18| "Model wait 0.0s " style 0-14 dim -6| -7| "Context · workspace-context" - style 0-26 dim -8| "Additional instructions from: " - style 0-43 dim -9| "nested/AGENTS.md " - style 0-15 dim -10| " " -11| "Render workspace context XML clearly. " - style 0-36 dim -12| -13| "/workspace/project (tui-staging) deepseek-v" +19| +20| "… earlier context was compacted … " + style 0-32 dim +21| +22| "/workspace/project (tui-staging) deepseek-v" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-43 dim -14| " dsh > " +23| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -15-17| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt index 0724786b40..81460f09b7 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt @@ -1,7 +1,7 @@ terminal 104x30 buffer=normal length=30 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=13 bufferRow=13 +cursor hidden column=7 viewportRow=22 bufferRow=22 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -13,25 +13,41 @@ buffer 3| 4| "Assistant " style 0-8 fg=bright-magenta bold underline -5| "Model wait 0.0s " +5| +6| "You " + style 0-2 fg=bright-magenta bold underline +7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. " +8| +9| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +10| "$ pnpm run test:coverage " + style 0-23 dim +11| "/workspace/project " + style 0-17 dim +12| "packages/ui/tui 100% " + style 0-19 dim +13| "… +1 lines (Ctrl+O to expand) " + style 0-28 dim +14| "1 test skipped " + style 0-13 dim +15| "coverage complete " + style 0-16 dim +16| "[exit 0] " + style 0-7 dim +17| "Model wait 0.0s " style 0-14 dim -6| -7| "Context · workspace-context" - style 0-26 dim -8| "Additional instructions from: nested/AGENTS.md " - style 0-45 dim -9| " " -10| "Render workspace context XML clearly. " - style 0-36 dim -11| -12| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +18| +19| "… earlier context was compacted … " + style 0-32 dim +20| +21| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -13| " dsh > " +22| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -14-29| +23-29| diff --git a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt index cf3915dab7..f599ef75cc 100644 --- a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt @@ -1,7 +1,7 @@ terminal 80x24 buffer=normal length=24 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=20 bufferRow=20 +cursor hidden column=7 viewportRow=21 bufferRow=21 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -16,35 +16,36 @@ buffer 5| 6| "You " style 0-2 fg=bright-magenta bold underline -7| "Old prompt with a long line that exercises wrapping before compaction. " -8| -9| "● Tool / bash / Run the coverage gate" +7| "Old prompt with a long line that exercises wrapping and stays visible after " +8| "compaction. " +9| +10| "● Tool / bash / Run the coverage gate" style 0-36 fg=green -10| "$ pnpm run test:coverage " +11| "$ pnpm run test:coverage " style 0-23 dim -11| "/workspace/project " +12| "/workspace/project " style 0-17 dim -12| "packages/ui/tui 100% " +13| "packages/ui/tui 100% " style 0-19 dim -13| "… +1 lines (Ctrl+O to expand) " +14| "… +1 lines (Ctrl+O to expand) " style 0-28 dim -14| "1 test skipped " +15| "1 test skipped " style 0-13 dim -15| "coverage complete " +16| "coverage complete " style 0-16 dim -16| "[exit 0] " +17| "[exit 0] " style 0-7 dim -17| "Model wait 0.0s " +18| "Model wait 0.0s " style 0-14 dim -18| -19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +19| +20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -20| " dsh > " +21| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -21-23| +22-23| diff --git a/packages/ui/tui/tests/snapshots/surface-replayed-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-replayed-compaction.expected.txt new file mode 100644 index 0000000000..81460f09b7 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/surface-replayed-compaction.expected.txt @@ -0,0 +1,53 @@ +terminal 104x30 buffer=normal length=30 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=22 bufferRow=22 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "You " + style 0-2 fg=bright-magenta bold underline +7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. " +8| +9| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +10| "$ pnpm run test:coverage " + style 0-23 dim +11| "/workspace/project " + style 0-17 dim +12| "packages/ui/tui 100% " + style 0-19 dim +13| "… +1 lines (Ctrl+O to expand) " + style 0-28 dim +14| "1 test skipped " + style 0-13 dim +15| "coverage complete " + style 0-16 dim +16| "[exit 0] " + style 0-7 dim +17| "Model wait 0.0s " + style 0-14 dim +18| +19| "… earlier context was compacted … " + style 0-32 dim +20| +21| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +22| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +23-29| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index aea3ca2037..b93cf73308 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' +import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact' import { createUserMessage, CallId, type ContentBlock , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId, type JsonValue, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' @@ -50,6 +51,7 @@ const CHECKPOINTS = [ 'surface-before-compaction', 'surface-after-compaction-narrow', 'surface-after-compaction-wide', + 'surface-replayed-compaction', 'model-selector', 'model-selector-filtered', 'model-switching', @@ -181,6 +183,67 @@ function appendToolResult( }, { surfaceOp: 'append' }) } +/** Frozen clock for the compaction fixtures; see the live scenario for why. */ +const COMPACTION_FIXTURE_TIME = new Date(2026, 6, 21, 14, 40, 0).getTime() + +/** The surface range a compaction checkpoint replaces, with its provenance. */ +interface CompactionRange { + start: number + end: number + sources: number[] +} + +/** + * Append one prompt / tool-call / tool-result step, the history a compaction + * shadows on the model surface and the transcript must keep showing. The prompt + * text is rendered verbatim; the tool card's body comes from `bash`'s static + * presenter, so the fixtures pin that the shadowed step's card survives rather + * than the result content below. + */ +function appendPreCompactionLog(session: Session): CompactionRange { + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping and stays visible after compaction.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const assistant = session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }], + source: { + kind: 'model', + ...{ provider: 'mock', model: 'deepseek-v4-flash' }, + }, + }), + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' }) + const result = session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: CallId('old-tool'), + content: [{ type: 'text', text: 'shadowed step tool output' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + return { start: user.seq, end: result.seq, sources: [user.seq, assistant.seq, result.seq] } +} + +/** Land a compaction: replace the range with the framed model-only checkpoint. */ +function appendCompactionCheckpoint(session: Session, range: CompactionRange): void { + session.append('user/message', createUserMessage({ + content: [{ + type: 'text', + text: '\nModel-only summary payload that must never reach the transcript.\n', + }], + source: COMPACT_CHECKPOINT_SOURCE, + }), { + surfaceOp: { op: 'replace', start: range.start, end: range.end }, + sourceEventSeqs: range.sources, + }) +} + function visualTool( name: string, call: NonNullable, @@ -684,61 +747,22 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) - it('pins compaction surface replacement and narrow-to-wide reflow', async () => { + it('pins preserved history, the compaction marker, and narrow-to-wide reflow', async () => { // Freeze the clock: the timing header hides zero-duration buckets, so a // real-clock millisecond tick between the fixture appends and the render // would flip `Tools 0.0s` in and out of the pinned header. - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 40, 0).getTime()) - let replacementStart = 0 - let replacementEnd = 0 - let replacementSources: number[] = [] + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME) + // The awaited setup always invokes beforeMount, so the range the checkpoint + // replaces is assigned by the time the appends below need it. + let compacted!: CompactionRange const harness = await setupSnapshot({ tools: ADVANCED_CARD_TOOLS, - beforeMount(session) { - const user = session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping before compaction.' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - const assistant = session.append('assistant/message', { - turn: 1, - step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }], - source: { - kind: 'model', - ...{ provider: 'mock', model: 'deepseek-v4-flash' }, - }, - }), - }, { surfaceOp: 'append' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' }) - const result = session.append('tool/result', { - turn: 1, - step: 1, - message: createToolResultMessage({ - callId: CallId('old-tool'), - content: [{ type: 'text', text: 'obsolete output that must disappear' }], - isError: false, - }), - }, { surfaceOp: 'append' }) - replacementStart = user.seq - replacementEnd = result.seq - replacementSources = [user.seq, assistant.seq, result.seq] - }, + beforeMount(session) { compacted = appendPreCompactionLog(session) }, }, { columns: 80, rows: 24 }) await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true }) await renderAfter(harness, () => { - harness.session.append('user/message', createUserMessage({ - content: [{ - type: 'text', - text: '\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n', - }], - source: { kind: 'plugin', plugin: 'workspace-context' }, - }), { - surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd }, - sourceEventSeqs: replacementSources, - }) + appendCompactionCheckpoint(harness.session, compacted) harness.terminal.resize(44, 18) }) await checkpoint('surface-after-compaction-narrow', harness.terminal, { includeScrollback: true }) @@ -749,6 +773,23 @@ describe('TUI terminal-state snapshots', () => { nowSpy.mockRestore() }) + // The resume path, which is what regressed for real users: the replacement is + // already stored when the terminal mounts, so the transcript comes from replay + // rather than from live appends. Pinned against the same log the live scenario + // ends on, at its wide size, so the two fixtures are directly comparable. + it('pins a stored compaction replayed at mount', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME) + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + beforeMount(session) { + appendCompactionCheckpoint(session, appendPreCompactionLog(session)) + }, + }, { columns: 104, rows: 30 }) + await checkpoint('surface-replayed-compaction', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + nowSpy.mockRestore() + }) + it('pins wrapped and explicit multiline shell-prompt input', async () => { const harness = await setupSnapshot({}, { columns: 44, rows: 18 }) await renderAfter(harness, () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index d99780dedb..445487065f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -19,6 +19,7 @@ import { createUserMessage, } from '@deepseek-ai/dsh-llm' import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' +import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact' import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider, type SkillSummary } from '@deepseek-ai/dsh-skill' @@ -4661,10 +4662,10 @@ describe('tool cards and surface replay', () => { await dispose(result) }) - it('rebuilds after a surface replacement and hides shadowed tool calls', async () => { + it('keeps append-origin history and marks a landed compaction, live and on rebuild', async () => { const result = await setup({ tools }) appendUser(result.session, 'old prompt') - const assistant = result.session.append('assistant/message', { + result.session.append('assistant/message', { turn: 1, step: 1, message: createMessage({ @@ -4687,21 +4688,116 @@ describe('tool cards and surface replay', () => { isError: false, }), }, { surfaceOp: 'append' }) - const start = result.session.surface.nodes[0] as number - result.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'summary replacement' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), { - surfaceOp: { op: 'replace', start, end: toolResult.seq }, - sourceEventSeqs: [start, assistant.seq, toolResult.seq], + // Result pruning rewrites one node's content in place: model-only, and no + // boundary in the conversation, so the terminal keeps the full output. + const originalResult = toolResult.data.message.content[0] + result.session.append('tool/result', { + ...toolResult.data, + message: freezeMessage({ + ...toolResult.data.message, + content: [{ ...originalResult, content: [{ type: 'text', text: 'pruned result copy' }] }] as [typeof originalResult], + }), + }, { + surfaceOp: { op: 'replace', start: toolResult.seq, end: toolResult.seq }, + sourceEventSeqs: [toolResult.seq], }) + const nodes = [...result.session.surface.nodes] + const checkpoint = result.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'model-only summary payload' }], + source: COMPACT_CHECKPOINT_SOURCE, + }), { + surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number }, + sourceEventSeqs: nodes, + }) + // A regenerated assistant message replaces one node without summarizing + // anything, so it marks no boundary either. + const generic = result.session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'generic replacement copy' }], + source: { + kind: 'model', + ...{ provider: 'mock', model: 'deepseek-v4-flash' }, + }, + }), + }, { surfaceOp: { op: 'replace', start: checkpoint.seq, end: checkpoint.seq }, sourceEventSeqs: [checkpoint.seq] }) + // Only a checkpoint carrying the compaction seam's source marks a boundary: + // another plugin replacing a node is model-only. + result.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'foreign plugin replacement copy' }], + source: { kind: 'plugin', plugin: 'other' }, + }), { surfaceOp: { op: 'replace', start: generic.seq, end: generic.seq }, sourceEventSeqs: [generic.seq] }) await tick() result.terminal.resize(89) await tick() - const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) - expect(lastFullRender).toContain('summary replacement') - expect(lastFullRender).not.toContain('old output') + const liveRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(liveRender).toContain('old prompt') + // The shadowed step keeps its card: one call row, one full result, no + // second card from the pruned copy. + expect(liveRender.split('$ printf hello')).toHaveLength(2) + expect(liveRender).toContain('third') + expect(liveRender.split('[exit 0]')).toHaveLength(2) + expect(liveRender.split('… earlier context was compacted …')).toHaveLength(2) + expect(liveRender).not.toContain('model-only summary payload') + expect(liveRender).not.toContain('generic replacement copy') + expect(liveRender).not.toContain('foreign plugin replacement copy') + + // Ctrl+R toggles reasoning, which rebuilds the transcript from the log; the + // replayed projection matches what the live appends produced, including the + // shadowed assistant message's tool card. + result.terminal.send('\x12') + await tick() + result.terminal.resize(90) + await tick() + const replayRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(replayRender).toContain('old prompt') + expect(replayRender.split('$ printf hello')).toHaveLength(2) + expect(replayRender).toContain('third') + expect(replayRender.split('[exit 0]')).toHaveLength(2) + expect(replayRender.split('… earlier context was compacted …')).toHaveLength(2) + expect(replayRender).not.toContain('model-only summary payload') + expect(replayRender).not.toContain('generic replacement copy') + expect(replayRender).not.toContain('foreign plugin replacement copy') + await dispose(result) + }) + + it('replays a stored compaction as preserved history plus its marker', async () => { + const result = await setup({ + beforeMount(session) { + appendUser(session, 'prompt before compaction') + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'reply before compaction' }], + source: { + kind: 'model', + ...{ provider: 'mock', model: 'deepseek-v4-flash' }, + }, + }), + }, { surfaceOp: 'append' }) + const nodes = [...session.surface.nodes] + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'stored model-only payload' }], + source: COMPACT_CHECKPOINT_SOURCE, + }), { + surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number }, + sourceEventSeqs: nodes, + }) + }, + }) + result.terminal.resize(89) + await tick() + + const mounted = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(mounted).toContain('prompt before compaction') + expect(mounted).toContain('reply before compaction') + expect(mounted.split('… earlier context was compacted …')).toHaveLength(2) + expect(mounted).not.toContain('stored model-only payload') await dispose(result) }) }) diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 1cfc20fd88..eddddcc704 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -53,6 +53,9 @@ { "path": "../commands" }, + { + "path": "../../compact/compact" + }, { "path": "../../skill/skill" }, diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index 98e40aa0d3..a74e6ec71b 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md -README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32 -README.zh.md: c89210b6955a661313ca9e0e82e43da5a4d1db79 +README.md: d62e75d110b8be339c5f9449b0834320f695ac99 +README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816 diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d234d6677b..d62e75d110 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -13,14 +13,19 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod ### Key Types -- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label. +- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label. - `AskUserQuestionOption` — `{ label, description? }`. +- `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below. - `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`. - `UserInteractionProvider` — UI implementation with `ask(request)`. -- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. +- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. +### Presentation intent + +`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of. + ## Role This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; `dsh-tui` and the host runtime provide interactive implementations. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index c89210b695..55258e85e5 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -13,14 +13,19 @@ ### 关键类型 -- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。 +- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。 - `AskUserQuestionOption`:`{ label, description? }`。 +- `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。 - `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。 - `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 -- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 +- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 +### 呈现意图 + +`intent` 声明某个问题本身就是一次已知形状的决定,因此认识该标签的 UI 可以照此呈现 —— `plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上 —— 而 `detail` 正是它自称在审阅的东西。 + ## 职责 这是接口包(package)。`@deepseek-ai/dsh-tool-ask-user` 等面向模型的消费方依赖此 seam;`dsh-tui` 和宿主运行时提供交互式实现。循环保持不变:工具调用等待 Promise,工具结果随后恢复正常的 agent loop(智能体循环)。 diff --git a/packages/ui/user-interaction/src/index.ts b/packages/ui/user-interaction/src/index.ts index 4b5caf6a55..506b3c6bfe 100644 --- a/packages/ui/user-interaction/src/index.ts +++ b/packages/ui/user-interaction/src/index.ts @@ -20,7 +20,8 @@ declare module 'cordis' { import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts' export type { - AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption, + AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem, + AskUserQuestionOption, } from './types.ts' /** Request for a human answer. */ @@ -86,6 +87,28 @@ export class UserInteractionService extends Service { if (request.questions.length === 0) { throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS') } + // A presentation intent asserts two things the types cannot: that the + // named approve label is one of this question's own options, and that a + // plan-review carries the plan it is a review of. A UI honouring the + // intent answers with that label, and shows that detail as the plan, so + // either gap would put a choice the asker never offered — or an approval of + // something invisible — in front of the user. Caught at the asker, where + // the mistake is, rather than in each UI. + for (const question of request.questions) { + const intent = question.intent + if (intent === undefined) continue + if (!(question.options ?? []).some(option => option.label === intent.approve)) { + throw new UserInteractionError( + `question ${question.id} declares intent ${intent.kind} whose approve label ` + + `${JSON.stringify(intent.approve)} names none of its options`, + 'BAD_INTENT') + } + if (question.detail === undefined) { + throw new UserInteractionError( + `question ${question.id} declares intent ${intent.kind} without the detail it reviews`, + 'BAD_INTENT') + } + } if (this.provider === undefined) { throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER') } diff --git a/packages/ui/user-interaction/src/types.ts b/packages/ui/user-interaction/src/types.ts index ddf3e43489..63949e81a1 100644 --- a/packages/ui/user-interaction/src/types.ts +++ b/packages/ui/user-interaction/src/types.ts @@ -13,6 +13,24 @@ export interface AskUserQuestionOption { description?: string } +/** + * A caller-declared presentation intent: the question IS a decision of this + * shape, so a UI that recognises the tag may present it as such instead of as a + * generic option list. Tagged so further intents can be added; a UI that does + * not know a tag renders the generic flow, and the answer encoding is identical + * either way — an intent shapes presentation only, never the protocol. + */ +export type AskUserQuestionIntent = { + /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ + kind: 'plan-review' + /** + * The option label that approves the plan; every other option declines it. + * Named rather than positional so no UI infers the verdict from option order. + * An `approve` naming no option of its own question is rejected at `ask()`. + */ + approve: string +} + /** One question in a user-interaction request. */ export interface AskUserQuestionItem { /** Stable caller-provided question id, echoed in the answer. */ @@ -27,6 +45,8 @@ export interface AskUserQuestionItem { options?: AskUserQuestionOption[] /** Whether more than one option may be selected. Defaults to single-select. */ multiSelect?: boolean + /** Optional presentation intent for capable UIs; absent asks for the generic option list. */ + intent?: AskUserQuestionIntent } /** Answer to one question. */ diff --git a/packages/ui/user-interaction/tests/user-interaction.spec.ts b/packages/ui/user-interaction/tests/user-interaction.spec.ts index adfbc9d4bb..df6b878cbd 100644 --- a/packages/ui/user-interaction/tests/user-interaction.spec.ts +++ b/packages/ui/user-interaction/tests/user-interaction.spec.ts @@ -83,4 +83,63 @@ describe('UserInteractionService', () => { .rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' }) expect(p.ask).not.toHaveBeenCalled() }) + + it('rejects an intent whose approve label names none of its own options', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const p = { ask: vi.fn(async () => ({ answers: [] })) } + ctx.userInteraction.registerProvider(p) + const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' } + + // A wrong label among offered options, and no options offered at all. + for (const options of [[{ label: 'Approve' }], undefined]) { + await expect(ctx.userInteraction.ask({ + questions: [{ + ...question, + ...(options === undefined ? {} : { options }), + intent: { kind: 'plan-review', approve: 'Ship it' }, + }], + })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' }) + } + expect(p.ask).not.toHaveBeenCalled() + }) + + it('rejects a plan-review intent on a question carrying no plan to review', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const p = { ask: vi.fn(async () => ({ answers: [] })) } + ctx.userInteraction.registerProvider(p) + + // Detail IS the plan for this intent, so a UI honouring it would ask the + // user to approve something they cannot see. + await expect(ctx.userInteraction.ask({ + questions: [{ + id: 'plan-review', question: 'Approve?', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + }], + })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' }) + expect(p.ask).not.toHaveBeenCalled() + }) + + it('passes an intent through once its approve label names an offered option', async () => { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const p = provider('Approve') + ctx.userInteraction.registerProvider(p) + const intent = { kind: 'plan-review', approve: 'Approve' } as const + + const result = await ctx.userInteraction.ask({ + questions: [ + { id: 'plain', question: 'Proceed?' }, + { + id: 'plan-review', question: 'Approve?', detail: '# Plan', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent, + }, + ], + }) + + expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }]) + expect(p.seen[0]?.questions[1]?.intent).toEqual(intent) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58fc06888f..8042d4b43f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,9 +249,15 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-auto': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-auto '@deepseek-ai/dsh-host-directory-picker-browse': specifier: workspace:^ version: link:../../packages/host/directory-picker-browse + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-native '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -3093,6 +3099,33 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/host/directory-picker-auto: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + '@deepseek-ai/dsh-host-directory-picker-browse': + specifier: workspace:^ + version: link:../directory-picker-browse + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../directory-picker-native + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/host/directory-picker-browse: dependencies: '@deepseek-ai/dsh-host-directory-picker': @@ -4213,6 +4246,46 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/settings/settings: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + + packages/settings/settings-local: + dependencies: + chokidar: + specifier: ^4.0.3 + version: 4.0.3 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../settings + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/skill/skill: dependencies: schemastery: @@ -5258,6 +5331,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 0682f78640..537abeff00 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1755, + "AGENTS.md": 1765, "docs/AGENTS.md": 1150, "docs/architecture.md": 1920, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 900 + "packages/README.md": 905 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4c30f8f2c5..67cd6c84f8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -186,6 +186,11 @@ export const LINK_MAP: Readonly> = { ToolRegistry: 'tools.md', ToolRestriction: 'tools.md', ToolSchema: 'tools.md', + SettingsNamespace: 'settings.md', + SettingsRegisterOptions: 'settings.md', + SettingsScope: 'settings.md', + SettingsDescriptor: 'settings.md', + SettingsUpdateSource: 'settings.md', AskUserQuestionAnswer: 'user-interaction.md', AskUserQuestionRequest: 'user-interaction.md', UserInteractionProvider: 'user-interaction.md', @@ -216,6 +221,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ /** Project types deliberately documented outside the core-data catalog. */ export const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md', + z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)', BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 23250e19e8..826e326436 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -152,6 +152,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'settings', + pkg: 'settings', + title: 'User-settings seam', + mode: 'seam', + implementations: ['settings-local'], + consumers: [], + note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet.', + }, { key: 'telemetry', pkg: 'session-telemetry', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index b092154b21..a77bdb6d95 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -273,10 +273,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ // never depends on the host PATH. `ctx.spillStore` is optional (read via // ctx.get) and does not affect the schemas, so no spill backend is mounted. await ctx.plugin(CatalogSearchBashExecutor) - await ctx.plugin(ToolFsSearch) + await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) }, note: - 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', + 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, { pkg: '@deepseek-ai/dsh-tool-pty', diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index e6cc11ae6e..28f854fe9b 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -197,7 +197,7 @@ describe('docsPages locale routes', () => { const translated = rootPages.filter(page => page.contentLocale === 'zh-CN') const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US') - expect(translated).toHaveLength(18) + expect(translated).toHaveLength(19) expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true) expect(fallbacks.map(page => page.source).sort()).toEqual([ 'docs/core-data-structures/commands.md', diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 9a67e64929..a4ea97ce31 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -135,31 +135,43 @@ describe('Oxlint gate', () => { }) }) -describe('Node 24 consumer graph', () => { - it('owns the eight-command pool and orders restored-artifact consumers', () => { +describe('Node 24 lane ownership', () => { + it('keeps the static lane source-only', () => { + const subject = withPnpmEntrypoint(() => gatesForMode('ci-static')) + + expect(subject.map(item => item.id)).not.toContain('build') + expect(subject.map(item => item.id)).not.toContain('doc-typecheck') + }) + + it('owns the build and orders its artifact consumers', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ - workers: 8, + workers: 10, source: 'ci-consumers gate count', }) expect(subject.map(item => item.id)).toEqual([ - 'lint-and-duplication', + 'build', 'node-compat', + 'publint', + 'built-package-invariants', + 'lint-and-duplication', 'snapshot', 'web-snapshot', - 'publint', + 'doc-typecheck', 'node-next-types', - 'built-package-invariants', 'built-bin-smoke', ]) - expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined() + expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build']) expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) - for (const id of ['snapshot', 'web-snapshot', 'node-next-types', 'built-bin-smoke']) { + for (const id of ['snapshot', 'web-snapshot', 'doc-typecheck', 'node-next-types', 'built-bin-smoke']) { expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' }) + expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({ + DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1', + }) expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', env: { DSH_SNAPSHOT: 'replay' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 167d226ff8..46bc72c833 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -195,7 +195,7 @@ export function gatesForMode(selected: Mode): Gate[] { case 'ci-linux-primary': return [...ciPrimaryGates(), webSnapshotGate(['built-package-invariants'])] case 'ci-static': - return ciStaticGates() + return ciStaticGates({ ownsBuild: false }) case 'ci-lint': return [ lintGate(), @@ -295,16 +295,21 @@ function nodeCompatSmokeGates(): Gate[] { ] } -function ciStaticGates(): Gate[] { +function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - pnpmScript('build', 'build'), + ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ - docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + includeDocTypecheck: options.ownsBuild, + ...options.ownsBuild + ? { + docTypecheckNeeds: ['build'], + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + } + : {}, docsBuildScript: 'docs:build:mpa', }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), @@ -326,23 +331,28 @@ function ciArtifactGates(): Gate[] { } function ciConsumerGates(): Gate[] { - const publicArtifacts = ['publint'] - const restoredBuild = ['built-package-invariants'] + const builtTree = ['build'] + const validatedBuild = ['built-package-invariants'] return [ + pnpmScript('build', 'build'), + pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), + pnpmScript('publint', 'publint', { needs: builtTree }), + builtPackageInvariantsGate(['publint']), pnpmScript('lint-and-duplication', 'check:ci:lint', { label: 'lint and duplication', - needs: restoredBuild, + needs: validatedBuild, + }), + snapshotGate(validatedBuild), + webSnapshotGate(validatedBuild), + pnpmScript('doc-typecheck', 'doc-typecheck', { + needs: validatedBuild, + env: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, }), - pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), - snapshotGate(restoredBuild), - webSnapshotGate(restoredBuild), - pnpmScript('publint', 'publint'), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', - needs: restoredBuild, + needs: validatedBuild, }), - builtPackageInvariantsGate(publicArtifacts), - builtBinSmokeGate(restoredBuild), + builtBinSmokeGate(validatedBuild), ] } @@ -377,7 +387,7 @@ function ciWindowsCompleteGates(): Gate[] { function ciWindowsObservationalGates(): Gate[] { return [ - ...ciStaticGates(), + ...ciStaticGates({ ownsBuild: true }), // Linux owns required lint, coverage, and snapshots; Windows omits those duplicates. pnpmScript('duplication', 'duplication'), pnpmScript('publint', 'publint', { needs: ['build'] }), @@ -410,7 +420,7 @@ function coverageGate(): Gate { // Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node, // plugins via real exports); repository-script snapshots execute their real source entry path. -// Build-owning modes wait on `build`; a restored-artifact mode passes its validation dependency. +// Callers wait either on `build` or on a validation gate that transitively owns that build. function snapshotGate(needs: string[] = ['build']): Gate { return pnpmScript('snapshot', 'test:snapshot', { env: { DSH_EXAMPLE_MODE: 'lib' }, @@ -458,6 +468,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { } function docSyncLeafGates(options: { + includeDocTypecheck?: boolean docTypecheckNeeds?: string[] docTypecheckEnv?: Record docsBuildScript?: 'docs:build' | 'docs:build:mpa' @@ -466,7 +477,9 @@ function docSyncLeafGates(options: { if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv return [ - pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions), + ...options.includeDocTypecheck === false + ? [] + : [pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions)], pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }), pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }), pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }), diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 7a4f678be8..0fb6aeb201 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -61,7 +61,8 @@ describe('global test invariant host', () => { return () => {} }) const fakeContext = { invariants: { register } } as unknown as Context - for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) { + for (const [rawPath, load] of Object.entries(testInvariantCompanions)) { + const companion = await load() const path = rawPath.replace(/^\.\.\//, '') expect(companion.default, path).toBeUndefined() const unwrapped = loader.unwrapExports(companion) as typeof companion diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index ce9ede2dff..62c0102588 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -12,8 +12,8 @@ import InvariantService from '@deepseek-ai/dsh-invariants' declare global { interface ImportMeta { - /** Eager Vite module-glob expansion used by the Vitest setup file. */ - glob(pattern: string, options: { eager: true }): Record + /** Lazy Vite module-glob expansion used by the Vitest setup file. */ + glob(pattern: string): Record Promise> } } @@ -25,9 +25,15 @@ export interface TestInvariantCompanion { apply(ctx: Context): Promise<() => void> } -/** Every package companion, discovered eagerly so coverage observes each registration. */ -export const testInvariantCompanions: Readonly> = - import.meta.glob('../packages/*/*/src/invariant.ts', { eager: true }) +/** + * Every package companion as a lazy loader keyed by glob path. Ordinary tests + * load only their owner's module; the exhaustive topology test loads and + * executes all of them, so aggregated coverage still observes every + * registration while per-file setup stops importing 168 companions and their + * transitive package sources. + */ +export const testInvariantCompanions: Readonly Promise>> = + import.meta.glob('../packages/*/*/src/invariant.ts') /** Manual-topology suites whose names cannot follow the focused invariant convention. */ const MANUAL_INVARIANT_TEST_EXCEPTIONS = [ @@ -36,7 +42,6 @@ const MANUAL_INVARIANT_TEST_EXCEPTIONS = [ ] as const interface InvariantHost { - readonly fibers: readonly PluginFiber[] readonly byCallback: ReadonlyMap readonly ready: Promise } @@ -102,39 +107,40 @@ export function testInvariantCompanionPaths(testPath: string): string[] { } function startInvariantHost(root: Context): InvariantHost { - const fibers: PluginFiber[] = [] const byCallback = new Map() - const mount = (plugin: Plugin, config?: unknown): void => { + const mount = (plugin: Plugin, config?: unknown): PluginFiber => { const fiber = originalPlugin.call(root.registry, plugin, config) const callback = root.registry.resolve(plugin) if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin') - fibers.push(fiber) byCallback.set(callback, fiber) + return fiber } - mount(InvariantService, { enabled: true }) + // The service mounts synchronously so the intercepted registration that + // started this host immediately finds its own fiber in byCallback. + // Companions load and mount inside the ready chain (after the service is + // active, so their startup is directly joinable); every joined root plugin + // awaits ready, so none starts ahead of its package checks. Tests plugging + // a companion directly must await an earlier root plugin first — the + // duplicate-mount failure otherwise is loud (owner name already reserved). + const serviceFiber = mount(InvariantService, { enabled: true }) const testPath = expect.getState().testPath ?? '' const companionPaths = testInvariantCompanionPaths(testPath) - for (const path of companionPaths) { - const companion = testInvariantCompanions[path] - if (companion === undefined) { - throw new Error(`test invariants: selected companion vanished at ${path}`) - } - if (!companion.inject.includes('invariants')) { - throw new Error(`test invariants: ${path} must inject the invariant service`) - } - mount(companion) - } - - const [serviceFiber, ...companionFibers] = fibers - if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted') - // A companion is initially PENDING on the invariant service, and Cordis - // Fiber.await() only joins work already in flight. Wait for the service to - // activate its dependants before joining their startup and failures. - const ready = serviceFiber.await() - .then(() => Promise.all(companionFibers.map(fiber => fiber.await()))) - .then(() => undefined) - const host = { fibers, byCallback, ready } + const ready = serviceFiber.await().then(async () => { + const companionFibers = await Promise.all(companionPaths.map(async (path) => { + const load = testInvariantCompanions[path] + if (load === undefined) { + throw new Error(`test invariants: selected companion vanished at ${path}`) + } + const companion = await load() + if (!companion.inject.includes('invariants')) { + throw new Error(`test invariants: ${path} must inject the invariant service`) + } + return mount(companion) + })) + await Promise.all(companionFibers.map(fiber => fiber.await())) + }) + const host = { byCallback, ready } hosts.set(root, host) return host } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d886b33df2..61f1ce96db 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -694,6 +694,11 @@ "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/types.ts" }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionIntent", + "source": "packages/ui/user-interaction/src/types.ts" + }, { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", @@ -1333,6 +1338,36 @@ "doc": "docs/core-data-structures/subprocess.md", "symbol": "SubprocessCollectedOutputs", "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsNamespace", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsRegisterOptions", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsApplies", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsScope", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsDescriptor", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsUpdateSource", + "source": "packages/settings/settings/src/index.ts" } ] } diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 1e0127a826..582da9dc65 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -33,6 +33,20 @@ const root = resolve(import.meta.dirname, '..') // specifiers resolve from apps/cli rather than the examples workspace. const appOverlayFiles = new Set(['examples/web-cordis/cordis.yml']) const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const + +/** The adaptive directory-picker chooser package (mounts a backend row at boot). */ +const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto' + +/** + * The backends the chooser mounts by runtime string (mirror of its exported + * `BACKEND_PACKAGES`), invisible to yml-row scanning: a composition mounting + * the chooser must resolve both, or keyless Linux CI (which only ever + * resolves `browse`) hides a dropped `-native` dependency until a macOS boot. + */ +const CHOOSER_BACKEND_PACKAGES = [ + '@deepseek-ai/dsh-host-directory-picker-native', + '@deepseek-ai/dsh-host-directory-picker-browse', +] const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar', resolve: data => typeof data === 'string', @@ -60,6 +74,7 @@ for (const file of files) { errors.push(...validateExampleResolution()) errors.push(...validateAppResolution()) +errors.push(...validateSourcePlaneResolution()) if (errors.length > 0) { console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') @@ -138,18 +153,75 @@ function validateAppResolution(): string[] { return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') } +/** + * Every configured specifier of a local workspace package must resolve through + * the tsconfig `paths` facade to a `.ts`/`.tsx` source file. The `dsh` source + * launch (tsx) and vitest resolve in the source plane; without a `paths` match + * they fall back to package `exports`, which reach built `lib/` — present on a + * built dev tree, absent on a clean one — so a missing mapping boots locally + * yet breaks every clean checkout. Anything but a `.ts`/`.tsx` hit (a `.d.ts` + * or `.js` under built `lib/`) is that artifact-plane fallback, not source. + */ +function validateSourcePlaneResolution(): string[] { + const violations: string[] = [] + const localPackages = localPackageDirectories() + const config = ts.readConfigFile(resolve(root, 'tsconfig.base.json'), path => ts.sys.readFile(path)) + if (config.error !== undefined) { + throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) + } + const { options, errors: optionErrors } = ts.convertCompilerOptionsFromJson( + (config.config as { compilerOptions?: unknown }).compilerOptions, + root, + 'tsconfig.base.json', + ) + if (optionErrors.length > 0) { + throw new Error(optionErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) + } + // convertCompilerOptionsFromJson leaves `pathsBasePath` unset, so relative + // `paths` targets resolve against the host's current directory; anchor it to + // the repository root to keep the gate cwd-independent. + const host: ts.ModuleResolutionHost = { + fileExists: path => ts.sys.fileExists(path), + readFile: path => ts.sys.readFile(path), + directoryExists: path => ts.sys.directoryExists(path), + getCurrentDirectory: () => root, + } + const sourceExtensions = new Set([ts.Extension.Ts, ts.Extension.Tsx]) + const containingFile = resolve(root, 'scripts/verify-cordis-config.ts') + const locationsBySpecifier = new Map>() + for (const reference of pluginReferences) { + const packageName = packageNameFromSpecifier(reference.name) + if (packageName === undefined || !localPackages.has(packageName)) continue + const locations = locationsBySpecifier.get(reference.name) ?? new Set() + locations.add(reference.file) + locationsBySpecifier.set(reference.name, locations) + } + for (const [specifier, locations] of locationsBySpecifier) { + const resolved = ts.resolveModuleName(specifier, containingFile, options, host).resolvedModule + if (resolved !== undefined && sourceExtensions.has(resolved.extension)) continue + violations.push(`${[...locations].join(', ')}: ${specifier} does not resolve to workspace source through tsconfig.base.json paths (add a mapping so the tsx source launch does not depend on built lib/)`) + } + return violations +} + function missingPluginDependencies( references: readonly PluginReference[], dependencies: Readonly>, manifestPath: string, ): string[] { const requiredPackages = new Map>() + const require = (packageName: string, file: string): void => { + const locations = requiredPackages.get(packageName) ?? new Set() + locations.add(file) + requiredPackages.set(packageName, locations) + } for (const reference of references) { const packageName = packageNameFromSpecifier(reference.name) if (packageName === undefined) continue - const locations = requiredPackages.get(packageName) ?? new Set() - locations.add(reference.file) - requiredPackages.set(packageName, locations) + require(packageName, reference.file) + if (packageName === CHOOSER_PACKAGE) { + for (const backend of CHOOSER_BACKEND_PACKAGES) require(backend, reference.file) + } } return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies ? [] diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 28ceeca2ad..615cc2832f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -80,6 +80,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' }, 'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers no model surface.' }, + 'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; registers no model surface.' }, 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, @@ -101,6 +102,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, + 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' }, + 'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 527dc44758..5a69af958d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/ui/commands/src/brand.ts"], + "@deepseek-ai/dsh-tui/prompt": ["./packages/ui/tui/src/prompt.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], @@ -93,6 +94,7 @@ "./packages/session-projection/*/src/invariant.ts", "./packages/session-title/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/settings/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -119,6 +121,7 @@ "@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"], "@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"], "@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"], + "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], "@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"], "@deepseek-ai/dsh-host-directory-picker-browse/*": ["./packages/host/directory-picker-browse/src/*"], "@deepseek-ai/dsh-host-directory-picker-native": ["./packages/host/directory-picker-native/src"], @@ -185,6 +188,7 @@ "./packages/session-projection/*/src", "./packages/session-query/*/src", "./packages/session-title/*/src", + "./packages/settings/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", "./packages/storage/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index b32c410961..c305be0ce5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -10,8 +10,10 @@ "include": [ "apps/web/tests/scaffold.ts", "apps/web/tests/support.ts", + "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", + "apps/web/tests/plan-review.e2e.ts", "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", @@ -72,6 +74,8 @@ { "path": "./packages/session-projection/session-projection-cache" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, + { "path": "./packages/settings/settings" }, + { "path": "./packages/settings/settings-local" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" }, @@ -181,6 +185,14 @@ { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, + { "path": "./packages/host/directory-picker-auto" }, + // Dual-face backend leaves stay client-registered (their tests and client + // halves are excluded above); these references only let the adaptive + // chooser's composition test import each backend's NODE entry, whose + // declarations carry no client-side Context merge — the mirror of the + // client aggregate's webserver reference. + { "path": "./packages/host/directory-picker-browse" }, + { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, diff --git a/vitest.config.ts b/vitest.config.ts index 750bb6ddf2..aa3e2b441b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -62,10 +62,12 @@ export default defineConfig({ plugins: [pathsPlugin()], test: { name: 'thread-safe', - // Node 24 has aborted in its CJS lexer from a macOS arm64 worker - // thread. A fork contains that external runtime failure to the test - // process; other hosts retain the lower-overhead thread pool. - pool: process.platform === 'darwin' ? 'forks' : 'threads', + // Node 24 has aborted in its CJS lexer (v8::ToLocalChecked Empty + // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS + // arm64 and later on Linux. A fork contains that external runtime + // failure to the test process; Windows keeps the thread pool, where + // the abort has not reproduced and process spawn is costlier. + pool: process.platform === 'win32' ? 'threads' : 'forks', setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ @@ -154,9 +156,11 @@ export default defineConfig({ 'packages/client/ui-sidebar/src/client/index.ts', 'packages/client/ui-skill/src/client/index.ts', 'packages/client/ui-workspace/src/client/index.ts', - 'packages/typert/generator/src/analyzer.ts', - 'packages/typert/generator/src/renderer.ts', - 'packages/typert/generator/src/cordis-catalog.ts', + // Typert generator: correctness is pinned by its fixture suites and + // the byte-for-byte catalog reproduction test; per-file coverage + // would put whole-workspace compiler analysis under v8 + // instrumentation — the coverage lane's longest tail. + 'packages/typert/generator/src/*.ts', 'packages/host/apiproxy/src/index.ts', 'packages/host/apiproxy/src/invariant.ts', 'packages/host/apiproxy/src/api-proxy.ts', diff --git a/website/docs.ts b/website/docs.ts index 1888cd908c..cbc8d9e627 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -256,6 +256,7 @@ const coreDataReference = pairedPages(([ ['sandbox.md', '沙箱', 'Sandboxing', 18], ['web.md', 'Web 访问', 'Web access', 19], ['persistence.md', '会话持久化', 'Session persistence', 20], + ['settings.md', '用户设置', 'User settings', 21], ] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ source: `docs/core-data-structures/${file}`, route: `reference/core-data-structures/${file}`,