mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
Merge master into the ID-only subagent and Team core
This commit is contained in:
+6
-6
@@ -9,11 +9,11 @@
|
||||
en: fca00eeb7a8d9abd
|
||||
zh: d7a9766f5322dff9
|
||||
/agent-note-session-projections-as-a-required-reader-seam/decision:
|
||||
en: 8d38ed9356085c7e
|
||||
zh: e15ede51415192f3
|
||||
en: ed3ee779d31afdba
|
||||
zh: 2ffad21003be59c5
|
||||
/agent-note-session-projections-as-a-required-reader-seam/alternatives-considered:
|
||||
en: aea3da939c90d240
|
||||
zh: 21e91c481cdd23b7
|
||||
en: 778e12e73a9a6900
|
||||
zh: 5d4f313e4ea87372
|
||||
/agent-note-session-projections-as-a-required-reader-seam/consequences:
|
||||
en: ab45eb87f7c22cdb
|
||||
zh: e65a97991a45164f
|
||||
en: b1935fb4cc8d9cc6
|
||||
zh: 0611d7033f5702a3
|
||||
|
||||
@@ -18,6 +18,8 @@ Owners acquire state through [awaited registration and retained handles](2026-10
|
||||
|
||||
`onChanged` publishes client-visible value changes only. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set.
|
||||
|
||||
`onEventChanged` groups the changes of one committed event. After every unit has been driven, it receives one `{ key, val }` entry per client-visible key whose view changed, in registration order, where `val` is the key's view. If a unit or an `onChanged` listener throws, it still receives the entries collected before the throw. Remote projection frames follow this grouping: `session/control` and `session/observeProjections` emit one frame per committed event with a `changes` array of the same entries, so a Client applies one event's changes and publishes its Session list once instead of once per key. Each entry names its key, so a key changed to `null` stays distinct from an unchanged key and no consumer pairs values with keys by position. `onChanged` remains for Host consumers that react to one key.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Default missing projected state.** This preserves more partial compositions but makes missing host state indistinguishable from a valid empty value. Rejected because official profiles mount the registry and configuration errors must fail explicitly.
|
||||
@@ -25,9 +27,14 @@ Owners acquire state through [awaited registration and retained handles](2026-10
|
||||
- **Use `snapshot()` for every read.** This keeps one method but computes unrelated wire views and encourages consumers to depend on batch transport data for host logic. Rejected in favor of typed single-key state reads.
|
||||
- **Send full host values to clients.** This avoids separate view types but exposes producer identities and policy knobs that no client consumes. Rejected in favor of explicit cropped views.
|
||||
- **Broadcast registry additions and removals across Host and mux streams.** The streams have no shared ordering, so clients need tombstones, buffered frames, and baseline retries to reconcile them. Rejected because plugin-key churn does not justify a second synchronization protocol.
|
||||
- **Coalesce per-key frames in the controller or Client.** Microtask batching or grouping frames by sequence number needs buffering and timing assumptions, while the registry already knows where each event's drive ends. Rejected in favor of grouping at the drive.
|
||||
- **Carry frame changes as a key-to-value object.** Consumers would infer the changed keys from object members and lose the registration order. Rejected in favor of an ordered array of `{ key, val }` entries.
|
||||
- **Carry parallel `keys` and `values` arrays.** This avoids repeating the entry field names, but every consumer must keep the two arrays aligned by index, and the wire types cannot express their equal length. Rejected in favor of self-contained entries.
|
||||
- **Replace `onChanged` with the event-level feed.** Host consumers such as forge react to one key and gain nothing from grouping. Rejected; both feeds remain.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Missing projection composition fails during activation; it never degrades to a default value.
|
||||
- Host consumers avoid repeated whole-registry snapshots and log scans.
|
||||
- Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set.
|
||||
- One committed event produces at most one Remote projection frame per Session, whose `seq` is that event's sequence number.
|
||||
|
||||
+7
@@ -18,6 +18,8 @@ Status: implemented
|
||||
|
||||
`onChanged` 只发布客户端可见值的变化。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。
|
||||
|
||||
`onEventChanged` 把一个已提交事件的变化归为一组。所有单元驱动完成后,它按注册顺序收到视图发生变化的每个客户端可见 key 一项 `{ key, val }`,`val` 是该 key 的视图。某个单元或 `onChanged` 监听器抛错时,它仍会收到抛错前已收集的各项。Remote projection 帧沿用这一分组:`session/control` 与 `session/observeProjections` 每个已提交事件发出一帧,携带由同样各项组成的 `changes` 数组,因此 Client 一次应用一个事件的变化,Session 列表只发布一次,而不是每个 key 一次。每项都写明自己的 key,因此改为 `null` 的 key 与未变化的 key 可以区分,消费方也不必按位置把值和 key 配对。`onChanged` 保留给只关心单个 key 的 Host 消费方。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **为缺失的投影状态提供默认值。** 这会保留更多不完整组合,但缺失 host 状态将无法与合法空值区分。正式 profile 已挂载注册表,配置错误必须显式失败,因此否决。
|
||||
@@ -25,9 +27,14 @@ Status: implemented
|
||||
- **每次读取都使用 `snapshot()`。** 这只保留一个方法,但会计算无关 wire view,并鼓励消费方让 host 逻辑依赖批量传输数据。改用类型化单 key 状态读取。
|
||||
- **向客户端发送完整 host 值。** 这避免单独的 view 类型,但会暴露客户端不消费的来源信息和策略旋钮。改用显式裁剪的 view。
|
||||
- **跨 Host 和 mux stream 广播注册表新增和移除。** 两条 stream 没有共享顺序,客户端因此需要 tombstone、缓冲帧和基线重试来协调。插件 key 变化不值得引入第二套同步协议。
|
||||
- **在 controller 或 Client 中合并逐 key 帧。** 按 microtask 攒批或按序号分组都需要缓冲和时序假设,而注册表本来就知道每个事件的驱动在哪里结束。改为在驱动处分组。
|
||||
- **把帧内的变化做成 key 到值的对象。** 消费方要从对象成员推断哪些 key 变化,也会丢失注册顺序。改用有序的 `{ key, val }` 数组。
|
||||
- **使用平行的 `keys` 与 `values` 两个数组。** 这样不必重复每项的字段名,但每个消费方都要按下标保持两个数组对齐,线路类型也无法表达两者等长。改用自带 key 的各项。
|
||||
- **用事件级变更流取代 `onChanged`。** forge 等 Host 消费方只关心单个 key,分组对它们没有好处。两条变更流都保留。
|
||||
|
||||
## 后果
|
||||
|
||||
- 缺少投影组合会在激活期间失败,绝不会降级为默认值。
|
||||
- host 消费方避免重复的全注册表快照和日志扫描。
|
||||
- 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。
|
||||
- 一个已提交事件对每个 Session 至多产生一个 Remote projection 帧,其 `seq` 是该事件的序号。
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@
|
||||
en: efded09f0cd30a8e
|
||||
zh: 5e16580a39ff3359
|
||||
/agent-note-session-observations-and-projection-owned-client-state/decision/client-merge-rules:
|
||||
en: fb8740be674e4708
|
||||
zh: 7dc0c89558f51792
|
||||
en: 135a93a2acecaf2a
|
||||
zh: 87b69116a64b80b5
|
||||
/agent-note-session-observations-and-projection-owned-client-state/decision/domain-applications:
|
||||
en: 4dcae9e00148b11e
|
||||
zh: acfd1b6831cf50fe
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ These distinctions prevent one overloaded `undefined` from representing cache mi
|
||||
|---|---|---|---|
|
||||
| Session list hints | Partial | Last durable checkpoint or bounded fallback cut | Unknown |
|
||||
| Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent |
|
||||
| Projection frame | One whole key | Event sequence carried by the frame | Not applicable |
|
||||
| Projection frame | Every key the frame lists, each a whole value | Event sequence carried by the frame | Unchanged |
|
||||
|
||||
The Client stores one row per key. A row the connected Host computed carries its sequence number: a newer baseline or frame replaces it, an equal or older input is ignored, and reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence. A list hint viewed from the persisted cache carries no comparable sequence number and yields to every Host-sequenced write ([projection cache listing identity and cached rows](2026-09-19-projection-cache-listing-identity-and-cached-rows.md)).
|
||||
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ Projection 的三种交付状态含义不同:
|
||||
|---|---|---|---|
|
||||
| Session list hints | 部分 | 上次持久 checkpoint 或有界 fallback cut | 未知 |
|
||||
| Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 |
|
||||
| Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 |
|
||||
| Projection frame | 帧列出的每个 key,各为完整值 | Frame 携带的 event sequence | 未变化 |
|
||||
|
||||
Client 为每个 key 保存一行。由建连 Host 算出的行带 sequence number:更新的 baseline 或 frame 会替换它,相同或更旧的输入被忽略,因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。从持久化缓存看出来的 list hint 不带可比的 sequence number,让位于所有 Host 排序过的写入([Projection cache 只读面按 lifecycle 身份匹配,客户端 store 区分 cached 与 sequenced 行](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。
|
||||
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
en: 45c63df5dac389eb
|
||||
zh: d72a5bcb94de22f1
|
||||
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/reads-and-subscriptions:
|
||||
en: 38df4f3c4fba2cc9
|
||||
zh: a34f3376faf26f0c
|
||||
en: a2439f02cb9654f9
|
||||
zh: 9de786792cba6a1d
|
||||
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/follow-ordering-and-ownership:
|
||||
en: 352ff87a3e9cd110
|
||||
zh: 4ac6366cecfc08a3
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ History reads return chronological records, `hasMore`, and an inclusive cursor.
|
||||
|
||||
`usingObservation({ sessionId, projection }, signal, callback?)` acquires the existing disposable SessionObservation. A callback borrows it until the callback settles; without a callback the caller owns disposal. Ordinary history, state, and Wait output reads do not request projections. Follow, exact projection reads, and subagent history descriptor validation request them. No read activates an Agent implicitly.
|
||||
|
||||
`observeEvent` and `observeProjections` install listeners without acquiring an Observation. Their initial `ready` confirms subscription, not a snapshot cursor, existence, parent-child validity, or Agent readiness. History establishes continuity from its first received event; projection updates replace individual keys and may share a sequence number. `observeState` instead begins with a complete state read and coalesces later Session and Agent invalidations.
|
||||
`observeEvent` and `observeProjections` install listeners without acquiring an Observation. Their initial `ready` confirms subscription, not a snapshot cursor, existence, parent-child validity, or Agent readiness. History establishes continuity from its first received event; each projection change frame covers one committed event and replaces only the keys it lists. `observeState` instead begins with a complete state read and coalesces later Session and Agent invalidations.
|
||||
|
||||
History, history observation, and Follow accept Address requests; projection and state operations accept Session IDs. All internal readers and listeners use Session IDs. History and Follow validate Address against their opening Observation at the Controller layer, including the existing subagent descriptor rules. Pure history observation does not acquire a read for entity validation. Gateway resource authorization remains independent: the registered Session resolver extracts the target ID from the request before access is decided.
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
`usingObservation({ sessionId, projection }, signal, callback?)` 获取已有的可释放 SessionObservation。有回调时租约借给回调直到其结束;无回调时由调用方释放。普通历史、状态及 Wait 输出读取不请求投影。Follow、精确投影读取和子会话历史 descriptor 校验请求投影。读取不会隐式激活 Agent。
|
||||
|
||||
`observeEvent` 和 `observeProjections` 安装监听而不获取 Observation。首个 `ready` 确认订阅生效,不代表快照游标、实体存在、父子关系有效或 Agent 就绪。历史从首个收到的事件建立连续性;投影更新替换单个键,多个更新可以共享序号。`observeState` 则从完整状态读取开始,并合并后续 Session 和 Agent 失效通知。
|
||||
`observeEvent` 和 `observeProjections` 安装监听而不获取 Observation。首个 `ready` 确认订阅生效,不代表快照游标、实体存在、父子关系有效或 Agent 就绪。历史从首个收到的事件建立连续性;每个投影变更帧对应一个已提交事件,只替换帧内列出的键。`observeState` 则从完整状态读取开始,并合并后续 Session 和 Agent 失效通知。
|
||||
|
||||
历史、历史观察和 Follow 接收 Address 请求;投影和状态操作接收 Session ID。所有内部读取器和监听器都使用 Session ID。History 和 Follow 在 Controller 层以开场 Observation 校验 Address,包括已有的子会话 descriptor 规则。纯历史观察不为实体校验获取读取。Gateway 资源授权与之独立:注册的 Session 解析器在访问判定前从请求提取目标 ID。
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Bilingual-pair consistency record for 2026-09-28-apply-patch-file-editing.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.md
|
||||
/agent-note-apply-patch-file-editing:
|
||||
en: 2ff887cb8deab0eb
|
||||
zh: b503b00d8690955c
|
||||
/agent-note-apply-patch-file-editing/problem:
|
||||
en: de9b76d89795ccec
|
||||
zh: 588d45b7d9ca9582
|
||||
/agent-note-apply-patch-file-editing/decision:
|
||||
en: e3e174513948d0dd
|
||||
zh: 80b13d7e40d38dee
|
||||
/agent-note-apply-patch-file-editing/alternatives-considered:
|
||||
en: fb743c9558cf24ed
|
||||
zh: e74bdfff00dea417
|
||||
/agent-note-apply-patch-file-editing/consequences:
|
||||
en: b58b7324663c3c90
|
||||
zh: 649c18a83e33e71d
|
||||
/agent-note-apply-patch-file-editing/testing:
|
||||
en: 82ae0a96fbae53bc
|
||||
zh: 9a8194bdb134ae8c
|
||||
/agent-note-apply-patch-file-editing/deferred:
|
||||
en: 9fa35fed544d9a9d
|
||||
zh: 32fced0939325341
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Apply patch file editing
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-28-apply-patch-file-editing.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A model editing several files or separated regions needs multiple literal-edit calls or a shell script. Shell and Python edits do not expose filesystem mutation intents, so a working directory without Git cannot reliably present those edits in the turn Review tab. A patch tool expresses related edits in one call while retaining guarded filesystem operations. #5121 asks for the capability and its effectiveness evaluation; #4556 supplies the broader motivation for efficient file editing.
|
||||
|
||||
## Decision
|
||||
|
||||
`apply_patch` accepts one Codex-style patch for add, update, delete, and move operations, including multiple files and hunks. The pure [dsh-fs-patch library](../../../../packages/fs/fs-patch/README.md) follows Codex `rust-v0.158.0` parsing, sequential matching, whitespace tolerance, and `NormalizeToLf` reconstruction; the matcher receives LF text only, so Codex `PreserveLineEndings` is not implemented.
|
||||
|
||||
The [filesystem service](../../../../packages/fs/fs/README.md) exposes `patchText`, `deleteFile`, and `moveFile`; local, sandboxed, and SSH providers implement them. `fs/delete-intent` requires the same present observation as `fs/edit-intent`, while move destinations use `fs/write-intent`. Version guards and sandbox enforcement remain with the provider. A move publishes its destination before deleting its source, and `sourceDeleted` distinguishes a completed move from a published destination whose source could not be removed.
|
||||
|
||||
The [tool executor](../../../../packages/fs/tool-fs/README.md) resolves paths and simulates expected state during whole-patch preflight, including repeated paths and moves, without writing files; a refused preflight commits nothing. Commit reobtains observation guards for each operation and stops at the first failure or cancellation without rollback. After an I/O or cancellation failure, a `stat` comparison checks whether every affected target retains its prior version; changed state or a failed comparison produces `unknown`, which requires rereading before retry. Reports retain completed, failed, unknown, and skipped operations, including a partially published move.
|
||||
|
||||
Commit reports return `isError: false` so completed diff metadata survives a stopped patch; their operation statuses and stopped flag expose the failure. Syntax and preflight rejection produce tool errors. Successful move summaries use `M <destination>`. Applied diff paths use canonical absolute `processPath` for client navigation, while operation records retain display paths and unresolved paths keep their patch spelling.
|
||||
|
||||
[Review capture](../../../../packages/deliverables/workspace-changes/README.md) uses filesystem intent listeners, not argument parsing. `tools/pre-execute` associates the execution with its turn recorder; prepended write, edit, and delete listeners first await the policy decision, then await capture before provider mutation. Moves capture both endpoints. Each turn retains its first baseline and compares actual final bytes, so repeated edits yield one net diff and reverted changes disappear. Existing size, count, time, location, and Session-lifetime limits remain.
|
||||
|
||||
`tool-fs.applyPatch` defaults to `false`; each shipped `tool-fs` row that enables `edit` states its own value, so turning the tool on or off is one line per composition, or a profile patch on the `tool-fs` id where no preset owns the row. The tool consults the `fs/*` intent waterfalls like `write` and `edit`, so a mounted observation policy requires a prior read and rejects stale versions for every patch operation. The shipped values of those rows and of the `fs-observation-policy` row are decided by [Shipped filesystem mutation defaults](2026-10-29-shipped-fs-mutation-defaults.md), which supersedes this note's opt-in baseline. The schema names its argument `input` as the Codex tool does: across 2,226 recorded DS-Bench and DeepSWE rollouts, 14% of `apply_patch` calls arrived with that key and were rejected while the argument was named `patch`. The tool registers its schema, guidance, native/PTC execution, and tool presentation through plugin effects; the agent loop is unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep using shell or Python for patches.** This leaves non-Git changes without filesystem intent capture and does not provide the requested model-facing editing tool.
|
||||
|
||||
**Reuse Codex markers with an independently strict matcher.** This reduces initial implementation work but makes familiar patches fail differently. A pinned Codex parser and matcher make deviations reviewable; Harness policies stay explicit.
|
||||
|
||||
**Make the whole patch transactional.** Cross-file rollback adds recovery state and can overwrite concurrent external edits. Preflight and ordered commits reject known failures early and expose partial results without promising rollback.
|
||||
|
||||
**Expand Review while adding the tool.** Rename presentation, persistent storage, SSH capture, and capture-failure policy are independent product decisions; existing whole-file capture and rendering cover this integration.
|
||||
|
||||
**Register the tool by default wherever `write` and `edit` are both enabled.** This would add a schema and a guidance section to every default model request and change every recorded request expectation before the tool's effect on ordinary sessions is measured; the explicit switch keeps the baseline composition unchanged and lets comparison runs opt in. The shipped defaults were decided afterwards in [Shipped filesystem mutation defaults](2026-10-29-shipped-fs-mutation-defaults.md).
|
||||
|
||||
## Consequences
|
||||
|
||||
One call can express related edits. A composition that enables the switch includes another tool schema and its guidance, so its recorded request expectations differ from a composition without it. Preflight cannot prevent a later conflict, cancellation, or storage failure; callers inspect operation statuses before retrying. Permissive upstream matching remains pinned and tested rather than tightened. Provider implementations and external subclasses must support three additional operations, and partial moves require handling both endpoints.
|
||||
|
||||
## Testing
|
||||
|
||||
Parser and matcher fixtures cover the pinned syntax, whitespace, repeated contexts, and EOF behavior. Provider and executor tests cover observation, version and sandbox refusals, repeated paths, partial moves, cancellation, and uncertain outcomes. The real-tool Review matrix runs with no repository and with Git unavailable, checking all operation kinds, first-to-last baselines, rejected edits, partial publication, reverted changes, capture ordering, malformed syntax, and path aliases. Real model Session recordings cover successful updates, read-policy rejection and retry, and partial execution; deterministic cancellation stays in executor tests.
|
||||
|
||||
## Deferred
|
||||
|
||||
SSH Review, closing-prose file references for `apply_patch`, and Codex `PreserveLineEndings`.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: apply_patch 文件编辑
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-28-apply-patch-file-editing.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
模型编辑多个文件或分离区域时,需要多次字面量编辑调用或 shell 脚本。shell 和 Python 编辑不发出文件系统修改 intent,因此没有 Git 的工作目录无法在轮次 Review tab 中可靠呈现这些修改。补丁工具用一次调用表达相关编辑,同时保留受保护的文件系统操作。#5121 要求实现能力并评测效果;#4556 提供高效文件编辑的更广泛动机。
|
||||
|
||||
## 决策
|
||||
|
||||
`apply_patch` 接收一份 Codex 风格补丁,支持新增、更新、删除和移动,以及多个文件和 hunk。[dsh-fs-patch 纯库](../../../../packages/fs/fs-patch/README.zh.md)遵循 Codex `rust-v0.158.0` 的解析、顺序匹配、空白容忍和 `NormalizeToLf` 重建。匹配器只接收 LF 文本,因此不实现 Codex `PreserveLineEndings`。
|
||||
|
||||
[文件系统服务](../../../../packages/fs/fs/README.zh.md)提供 `patchText`、`deleteFile` 和 `moveFile`;本地、沙箱和 SSH 提供方均实现它们。`fs/delete-intent` 与 `fs/edit-intent` 要求相同的存在观察,移动目标使用 `fs/write-intent`。版本防护和沙箱约束仍由提供方负责。移动先发布目标,再删除源;`sourceDeleted` 区分已完成的移动与目标已发布但源未能删除的情况。
|
||||
|
||||
[工具执行器](../../../../packages/fs/tool-fs/README.zh.md)在整份补丁预检时解析路径并模拟预期状态,包括重复路径和移动,不写入文件;预检拒绝时没有提交。提交时为每条操作重新获取观察防护,在首次失败或取消时停止,不回滚。I/O 或取消失败后,`stat` 比较检查所有受影响目标是否仍保持先前版本;状态变化或比较失败会产生 `unknown`,要求重试前重新读取。报告保留已完成、失败、未知和跳过的操作,包括部分发布的移动。
|
||||
|
||||
提交报告返回 `isError: false`,让已完成的 diff 元数据在补丁中止后仍被保留;操作状态和 stopped 标记暴露失败。语法和预检拒绝产生工具错误。成功移动的摘要使用 `M <destination>`。已应用 diff 的路径采用规范绝对 `processPath`,供客户端导航使用;操作记录保留显示路径,未解析的路径保留补丁原始写法。
|
||||
|
||||
[Review 采集](../../../../packages/deliverables/workspace-changes/README.zh.md)使用文件系统 intent 监听器,不解析参数。`tools/pre-execute` 将执行关联到轮次记录器;前置的 write、edit 和 delete 监听器先等待策略决策,再等待捕获完成,然后提供方才修改文件。移动捕获两端。每轮保留首次基线,比较实际最终字节,因此重复编辑形成一份净 diff,还原的变化不再显示。现有大小、数量、时间、位置和 Session 生命周期限制保持适用。
|
||||
|
||||
`tool-fs.applyPatch` 默认为 `false`;每个启用 `edit` 的随发行版交付的 `tool-fs` 行都显式写出自己的值,因此开关该工具是每个组合一行改动,没有 preset 拥有该行时可通过针对 `tool-fs` id 的 profile patch 开启。该工具像 `write` 和 `edit` 一样咨询 `fs/*` intent waterfall,因此已挂载的观察策略对每个补丁操作要求先读并拒绝过期版本。这些行以及 `fs-observation-policy` 行随发行版交付的值由[随发行版交付的文件系统修改默认值](2026-10-29-shipped-fs-mutation-defaults.zh.md)决定,该 Note 取代本 Note 的 opt-in 基线。schema 沿用 Codex 的参数名 `input`:在 2,226 条 DS-Bench 与 DeepSWE 录制轨迹中,14% 的 `apply_patch` 调用带着这个键发出,而参数名为 `patch` 时它们都被拒绝。工具通过插件 effect 注册 schema、指导文本、native/PTC 执行与调用展示;不修改 agent loop(智能体循环)。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**继续使用 shell 或 Python 打补丁。** 这使非 Git 修改缺少文件系统 intent 采集,也不能提供所需的模型文件编辑工具。
|
||||
|
||||
**复用 Codex 标记但采用独立的严格匹配器。** 这能减少初期实现工作,但会让熟悉的补丁以不同方式失败。固定 Codex 解析器和匹配器使偏差可审查;Harness 策略继续显式存在。
|
||||
|
||||
**将整份补丁做成事务。** 跨文件回滚增加恢复状态,还可能覆盖并发外部修改。预检和顺序提交可以提早拒绝已知失败,并公开部分结果,而不承诺回滚。
|
||||
|
||||
**加入工具时同时扩展 Review。** 重命名展示、持久存储、SSH 采集和捕获失败策略是独立产品决策;既有整文件采集和展示覆盖本次接入。
|
||||
|
||||
**在同时启用 `write` 与 `edit` 的地方默认注册该工具。** 这会在尚未衡量该工具对普通会话的影响之前,给每个默认模型请求增加一个 schema 和一段指导文本,并改变所有录制请求的预期;显式开关保持基线组合不变,让对比评测自行选择开启。随发行版交付的默认值随后在[随发行版交付的文件系统修改默认值](2026-10-29-shipped-fs-mutation-defaults.zh.md)中决定。
|
||||
|
||||
## 后果
|
||||
|
||||
一次调用可以表达相关编辑。开启该开关的组合会包含额外的工具 schema 及其指导文本,因此其录制请求的预期与未开启的组合不同。预检不能阻止之后的冲突、取消或存储失败;调用方在重试前检查操作状态。上游宽松匹配保持固定版本并受到测试约束,不会被收紧。提供方实现和外部子类必须支持三个新增操作,部分移动要求处理两端状态。
|
||||
|
||||
## 测试
|
||||
|
||||
解析器和匹配器 fixture 覆盖固定版本的语法、空白、重复上下文与 EOF 行为。提供方和执行器测试覆盖观察、版本和沙箱拒绝、重复路径、部分移动、取消和未知结果。真实工具 Review 矩阵分别在无仓库和 Git 不可用环境运行,检查所有操作类型、首末基线、被拒编辑、部分发布、还原变化、采集顺序、无效语法与路径别名。真实模型 Session 录制覆盖成功更新、读取策略拒绝后重试与部分执行;确定性取消保留在执行器测试中。
|
||||
|
||||
## 延期工作
|
||||
|
||||
SSH Review、`apply_patch` 的收尾文案文件引用,以及 Codex `PreserveLineEndings`。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-29-shipped-fs-mutation-defaults.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.md
|
||||
/agent-note-shipped-filesystem-mutation-defaults:
|
||||
en: ca322529f759f46a
|
||||
zh: a8881e2284631255
|
||||
/agent-note-shipped-filesystem-mutation-defaults/problem:
|
||||
en: 57dcac3bdbcf1c21
|
||||
zh: f5bf64888e6dd973
|
||||
/agent-note-shipped-filesystem-mutation-defaults/decision:
|
||||
en: bcdaca6bff9411b7
|
||||
zh: 7adfc3a9b9d8853b
|
||||
/agent-note-shipped-filesystem-mutation-defaults/alternatives-considered:
|
||||
en: 1249a7868ff2a069
|
||||
zh: 739f191121bf5293
|
||||
/agent-note-shipped-filesystem-mutation-defaults/consequences:
|
||||
en: 3def7f0443b41d34
|
||||
zh: 70df709d1fcbe8fa
|
||||
/agent-note-shipped-filesystem-mutation-defaults/testing:
|
||||
en: 625a8af588dc0ba8
|
||||
zh: 9d489f1bbed14fb3
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Shipped filesystem mutation defaults
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-29-shipped-fs-mutation-defaults.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Apply patch file editing](2026-09-28-apply-patch-file-editing.md) shipped `apply_patch` opt-in and kept `dsh-fs-observation-policy` mounted, so every base-backed profile refused `write`, `edit`, and `apply_patch` on a file the model had not read or that had changed since the read. Benchmark runs of the shipped compositions with the tool enabled and with the policy disabled were compared against the baseline, and the shipped defaults were decided from those results in discussion between the maintainers.
|
||||
|
||||
## Decision
|
||||
|
||||
- The `dsh-base` `tool-fs` row (headless, SDK, and ACP) and the Web `standard` preset state `applyPatch: true`. The Web `ptc` and `cordis` presets state `applyPatch: false`, and the shipped [PTC overlay](../../../../apps/cli/config/examples/ptc.yml) sets the same on the base row, so the SDK and Desktop PTC tool sets stay equal without a parity exception.
|
||||
- The `dsh-base` `fs-observation-policy` row ships `disabled: true`. Every mutating tool in a base-backed profile runs against the bare provider: `write` overwrites, `edit` replaces the unique literal match, and `apply_patch` applies hunks on context match alone. A deployment restores the guard for all three with `disabled: false` on that row. `sdk-minimal` stacks no `dsh-base` and is unaffected.
|
||||
- The `write` and `edit` guidance keeps asking for a prior read without naming the policy; the `apply_patch` guidance asks for enough hunk context to match one place instead of a prior read.
|
||||
- The policy package is classified as an optional product package, since no default composition mounts it. The two recordings that capture a policy refusal, `fs-policy-reject` and `apply-patch-rejected`, re-enable the row through a per-scenario `runtime.cordis.yml`; the fixed-budget compaction scenarios pin `applyPatch: false` in both their live and replay patches because their thresholds were tuned to the recorded tool set.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Enable `apply_patch` only in the Web `standard` preset.** The SDK/Desktop parity check compares the SDK profile's `standard` tool set with the Desktop preset, so a Web-only change needs a permanent parity exception for a difference with no product reason; enabling the base row keeps the two equal.
|
||||
|
||||
**Disable the policy for `apply_patch` only.** A `tool-fs` switch that skipped the intent waterfalls for patches alone was built and evaluated, but it left `write` and `edit` under a guard the shipped compositions no longer wanted and added a second configuration axis beside the policy row; disabling the row decides all three tools in one place and keeps the policy package intact for deployments that want it.
|
||||
|
||||
**Enable `apply_patch` in the PTC presets too.** Under PTC the tool is a `run_code` binding rather than a JSON schema, so the cost is small, but the PTC compositions were evaluated without it; the overlay and the Web preset keep the evaluated tool set.
|
||||
|
||||
## Consequences
|
||||
|
||||
Default model requests in base-backed profiles carry the `apply_patch` schema and guidance, so every recorded request expectation outside the fixed-budget compaction scenarios changed. A model can overwrite or edit a file it never read; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/fs-observation-policy-default-off/guide.md) names the one-line patches that restore the guard or drop the tool. Sandbox confinement, Review capture, and `fs/observed` events are unchanged.
|
||||
|
||||
## Testing
|
||||
|
||||
`tool-fs` unit tests cover the unguarded paths (`policy: false`). The keyless SDK smoke edits an existing file the model never read through the shipped `sdk` profile and checks the bytes on disk. `desktop-tools.expected.e2e.ts` compares the SDK and Desktop `standard` and `ptc` tool sets. The recorded corpus replays with the shipped defaults; `fs-policy-reject` and `apply-patch-rejected` replay with the policy re-enabled.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: 随发行版交付的文件系统修改默认值
|
||||
|
||||
状态:implemented
|
||||
|
||||
[English](2026-10-29-shipped-fs-mutation-defaults.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[Apply patch 文件编辑](2026-09-28-apply-patch-file-editing.zh.md)以 opt-in 方式交付 `apply_patch`,并保持 `dsh-fs-observation-policy` 挂载,因此所有叠加 base 的 profile 都会拒绝对模型未读过、或读后已变化的文件执行 `write`、`edit` 与 `apply_patch`。维护者对随发行版交付的组合分别在开启该工具和禁用该策略的情况下跑了 benchmark,与基线比较后讨论得出了随发行版交付的默认值。
|
||||
|
||||
## 决策
|
||||
|
||||
- `dsh-base` 的 `tool-fs` 行(headless、SDK、ACP)与 Web 的 `standard` preset 写出 `applyPatch: true`。Web 的 `ptc`、`cordis` preset 写出 `applyPatch: false`,随发行版交付的 [PTC overlay](../../../../apps/cli/config/examples/ptc.yml) 在 base 行上也设为 `false`,因此 SDK 与 Desktop 的 PTC 工具集保持一致,无需一致性例外。
|
||||
- `dsh-base` 的 `fs-observation-policy` 行以 `disabled: true` 交付。叠加 base 的 profile 中每个修改工具都直接作用于裸提供方:`write` 覆盖,`edit` 替换唯一的字面匹配,`apply_patch` 只凭上下文匹配应用 hunk。部署在该行上写 `disabled: false` 即可为三者恢复防护。`sdk-minimal` 不叠加 `dsh-base`,不受影响。
|
||||
- `write` 与 `edit` 的提示语仍要求先读,但不再点名该策略;`apply_patch` 的提示语要求给每个 hunk 足够的上下文以匹配唯一位置,而非先读。
|
||||
- 由于没有默认组合挂载策略包,它被归类为可选产品包。录的是策略拒绝的两个场景 `fs-policy-reject` 与 `apply-patch-rejected` 通过各自的 `runtime.cordis.yml` 把该行开回来;固定 token 预算的压缩场景在 live 与 replay patch 中都钉住 `applyPatch: false`,因为它们的阈值是按录制时的工具集调的。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只在 Web `standard` preset 开启 `apply_patch`。** SDK/Desktop 一致性检查会把 SDK profile 的 `standard` 工具集与 Desktop preset 比较,仅改 Web 需要为一个没有产品理由的差异永久保留一致性例外;开启 base 行让两边保持一致。
|
||||
|
||||
**只对 `apply_patch` 关闭策略。** 曾实现并评测过一个让补丁跳过 intent waterfall 的 `tool-fs` 开关,但它让 `write` 与 `edit` 继续受随发行版交付的组合已不需要的防护,并在策略行之外增加了第二个配置维度;禁用该行在一处决定三个工具,并为需要防护的部署完整保留策略包。
|
||||
|
||||
**在 PTC preset 中也开启 `apply_patch`。** PTC 下该工具是 `run_code` 内的绑定而非 JSON schema,代价很小,但 PTC 组合是在没有它的情况下评测的;overlay 与 Web preset 保持评测时的工具集。
|
||||
|
||||
## 后果
|
||||
|
||||
叠加 base 的 profile 的默认模型请求带有 `apply_patch` schema 与提示语,因此固定预算压缩场景之外的所有录制请求预期都变化了。模型可以覆盖或编辑从未读过的文件;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/fs-observation-policy-default-off/guide.zh.md)给出恢复防护或去掉该工具的一行 patch。沙箱围栏、Review 采集与 `fs/observed` 事件不变。
|
||||
|
||||
## 测试
|
||||
|
||||
`tool-fs` 单元测试覆盖无防护路径(`policy: false`)。keyless SDK smoke 通过随发行版交付的 `sdk` profile 对模型从未读过的既有文件执行 edit 并检查磁盘字节。`desktop-tools.expected.e2e.ts` 比较 SDK 与 Desktop 的 `standard` 和 `ptc` 工具集。录制 corpus 以随发行版交付的默认值回放;`fs-policy-reject` 与 `apply-patch-rejected` 在策略开回的情况下回放。
|
||||
@@ -0,0 +1,43 @@
|
||||
# Bilingual-pair consistency record for 2026-10-29-event-triggered-automation-tasks.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.md
|
||||
/agent-note-event-triggered-automation-tasks:
|
||||
en: fe57a78907eab083
|
||||
zh: 4ac96eeb365ac012
|
||||
/agent-note-event-triggered-automation-tasks/problem:
|
||||
en: 491a791a201d0b2c
|
||||
zh: 5a98f693ac4b5e77
|
||||
/agent-note-event-triggered-automation-tasks/proposal:
|
||||
en: 7906d9f791e0ba02
|
||||
zh: 3a083d451344f072
|
||||
/agent-note-event-triggered-automation-tasks/proposal/layers:
|
||||
en: 8bf2fc8ccb162c59
|
||||
zh: e993b9ae72f8d9a2
|
||||
/agent-note-event-triggered-automation-tasks/proposal/sessions:
|
||||
en: 7ec83684c8c19e31
|
||||
zh: 27df2725836e5f7b
|
||||
/agent-note-event-triggered-automation-tasks/proposal/configuration:
|
||||
en: 21be7e27081aff54
|
||||
zh: 7973f7f0d064db35
|
||||
/agent-note-event-triggered-automation-tasks/proposal/mechanisms:
|
||||
en: 2403ec84b12279ec
|
||||
zh: 7e245af54dcf4a1a
|
||||
/agent-note-event-triggered-automation-tasks/proposal/product-scope:
|
||||
en: c003c908f407ccb2
|
||||
zh: 6113569a44d52202
|
||||
/agent-note-event-triggered-automation-tasks/proposal/event-triggers-as-infrastructure:
|
||||
en: 4c62e16718896e9b
|
||||
zh: 9c61ea9b6e1f4f22
|
||||
/agent-note-event-triggered-automation-tasks/proposal/delivery-order:
|
||||
en: 79cf498bf3566051
|
||||
zh: 4537f1bd3df527b4
|
||||
/agent-note-event-triggered-automation-tasks/alternatives-considered:
|
||||
en: 21df079f7d7c3f4d
|
||||
zh: 67f4ab221aacf2cf
|
||||
/agent-note-event-triggered-automation-tasks/acceptance-criteria:
|
||||
en: 504feb2cc59f4457
|
||||
zh: c78cd50f2f336562
|
||||
/agent-note-event-triggered-automation-tasks/risks:
|
||||
en: 35edb8d5b6a1cfa4
|
||||
zh: cb8dbc339c6055be
|
||||
@@ -0,0 +1,153 @@
|
||||
# Agent Note: Event-triggered automation tasks
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-10-29-event-triggered-automation-tasks.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Automation tasks run only on time rules today. The Host stores each rule, and every due time queues the task's instruction as a follow-up message in the task's original Session ([Host-owned scheduled messages](../../implemented/architecture/2026-09-16-host-schedule-storage.md)); the model creates and edits tasks through preset-scoped tools ([Preset-scoped Schedule tools](../../implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md)). Work that should start when something else changes, such as a pull request's checks failing, a reviewer requesting changes, or a message arriving, cannot be expressed as a task, so users check those sources by hand across Sessions.
|
||||
|
||||
Reacting to changes brings two problems that time rules do not have.
|
||||
|
||||
- **Cost and noise.** Most observations of a source contain nothing worth a model turn. If every observation reaches the model and silence depends on the model replying with a marker, each poll costs a full turn, and a model that holds a messaging tool reports "nothing new" anyway.
|
||||
- **Untrusted input.** Event content is written by other people: comments, reviews, messages. A turn started by that content must not edit, push, or send anything before the user has seen it.
|
||||
|
||||
## Proposal
|
||||
|
||||
An automation task becomes an instruction, a source with parameters, and a Session mode. Time is one source among several, so a scheduled task is an automation task whose source is time. Each observation passes three layers, and only the last one calls a model: a source event passes the trigger's filter and becomes a candidate, the candidate is delivered to a Session where the model judges it, and only a notify call produces a notice for the user. This partially supersedes the two notes linked above: tasks stop being bound to their original Session, and task records stop meaning inbox delivery only.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Task["Task: instruction, source parameters, Session mode<br/>written by the model, shown read-only"]
|
||||
Prov["Source providers: plugins, code<br/>time, GitHub; later mail, IM, memory"]
|
||||
Trig["Event trigger: shared code, state per task<br/>compare with stored state, match parameters, merge"]
|
||||
Rec[("Run records")]
|
||||
Src{"Source"}
|
||||
Exec["Run the instruction in the target Session<br/>with its permissions"]
|
||||
RO["Read-only turn in the task's own Session<br/>the model judges the candidate"]
|
||||
Note["Notice: action card, unread mark,<br/>system notification"]
|
||||
Quiet["No notice"]
|
||||
Acc["User accepts: the action runs in the Session<br/>that created the task, with its permissions"]
|
||||
Task --> Trig
|
||||
Prov -- "source events" --> Trig
|
||||
Trig -- "each change or failure" --> Rec
|
||||
Trig -- candidate --> Src
|
||||
Src -- time --> Exec
|
||||
Src -- "other sources" --> RO
|
||||
RO -- notify --> Note
|
||||
RO -- "no notify call" --> Quiet
|
||||
Note --> Acc
|
||||
```
|
||||
|
||||
| Layer | Decides | Model call | Output |
|
||||
|---|---|---|---|
|
||||
| Source provider | What changed in the source | No | Events |
|
||||
| Event trigger | Whether the change matters to this task | No | A run record; a candidate or nothing |
|
||||
| Agent | Whether the user needs to act, and what to propose | Only for delivered candidates | A notice through notify, or nothing |
|
||||
|
||||
### Layers
|
||||
|
||||
**Source providers** are code, packaged as plugins; they provide event sources and are unrelated to model, SSH, or credential providers. A provider owns one kind of source: it polls or subscribes, reports changes as events with a subject, a one-line summary, detail, and links, and declares the JSON schema of the parameters a task can set. One provider serves every task of its kind. Time is the first provider, and today's schedule rules become its parameters. Any installed plugin can register a provider, including one written in Creator mode; a task can name only a registered provider.
|
||||
|
||||
**The event trigger** is shared code, registered per source kind and version; each task holds only its parameters and the source state it last observed. For each observation it compares the new state with the stored one, and an observation without a change only updates the last-checked time. A check that turns from passing to failing is a change, a check that stays failing is not, and a pass followed by a new failure is a change again. For a change, the trigger matches the task's conditions, which are parameter fields such as conclusion is failure, author is the user, or commenter is not a bot; merges changes to the same subject inside the provider's merge window, so one push that fails three checks becomes one event; stores the new state with the task; and either delivers a candidate to the Session or drops the change. Each change and each failure writes a run record, listed in the task's Records tab next to today's delivery records: candidate delivered, filtered with its reason, or failed. Consecutive failures with the same cause share one record with a count. Delivery ends the trigger's work: it does not wait for the turn or read a receipt. One queue per task orders its evaluations.
|
||||
|
||||
**The agent** receives the candidate as a message in the target Session, together with the task's instruction in the user's words. What the turn may do depends on the source.
|
||||
|
||||
- **Time**: the turn runs the instruction with the Session's permissions, and its reply is the result, as scheduled tasks behave today.
|
||||
- **Other sources**: the turn gets the read-only tool set described under Mechanisms. It can read files and the source's state, such as check logs and pull request diffs, but has no tool that edits, pushes, or sends. One new tool, **notify** (`automation_notify`), takes a title, a summary, and a proposed action and produces a notice: an [action card](../../../../packages/experimental/action-cards/README.md) with the proposed action in the Session, the Session's unread mark, and a desktop system notification. The model decides whether to call it. A turn without notify produces no notice; there is no end tool, and nothing checks how the turn ended. When the user accepts the card, the Host sends a follow-up to the Session that handles the task, as described under Sessions; the follow-up names the task and the event and quotes the proposed action as the automation's proposal, not as the user's words, and that turn runs with that Session's permissions.
|
||||
|
||||
### Sessions
|
||||
|
||||
A task delivers to its target Session in one of three modes: a specified Session, a new Session per trigger, or a new Session on the first trigger that later triggers reuse. Time tasks keep today's target, the Session they were created in. Event tasks separate judging from handling. The read-only turn runs in the task's own Session, created on the first trigger and reused afterwards, because it needs the instruction, the event, and the task's earlier decisions rather than the long history of the conversation the task came from. When the model creates a task in a conversation, it writes the facts the judgment needs into the instruction, such as which check is known to be flaky. When the user accepts a card, the proposed action goes to the Session the task was created in, where the code and its history are. The automation page's New action already creates tasks in a new Session, which then handles them; a task enabled from an official template, which a later step adds, continues in its own Session. The user can change the mode on the task.
|
||||
|
||||
No task runs while the Host is stopped. On restart, each task compares the current source state with its stored state once and does not replay intermediate changes. Time keeps today's rule: a recurring task runs its latest missed occurrence once.
|
||||
|
||||
### Configuration
|
||||
|
||||
Configuration has two layers. Provider code decides what can be observed and how; task parameters decide what one task watches. The model writes the parameters from the user's words through the task tools, and the provider's schema validates them. A task carries no scripts or other code, so observing something new means changing or installing a provider plugin. The automation page shows source parameters read-only, and they change through conversation; the time editor stays.
|
||||
|
||||
### Mechanisms
|
||||
|
||||
- **Event-turn tool set.** Sandbox read-only mode limits only the filesystem, so it cannot stop a push or an outbound request. An event turn is offered a fixed tool set instead of the Session's: file reads and searches, the source provider's read operations, and `automation_notify`. Shell, file writes, web fetch, and MCP tools are absent. The tool set is recorded in the Session log, and a user message queued during the turn starts a normal turn after it ends.
|
||||
- **Notify card.** The automation package registers an action-card kind, and `automation_notify` creates its card through the action-card service; the tool exists only in event turns, so no skill load precedes it. Accepting runs the kind's handler, which queues the follow-up through the Host, so the handling Session need not be open. A newer card replaces an unanswered older one in the same Session, as the service does today; the older card is in that Session's history, so the newer turn sees it and carries over what is still open. A refused card returns an error to the turn.
|
||||
- **Unread mark.** The client derives the unread mark from turn completion. A task's own Session is marked as an automation Session, and for such a Session the client shows the mark only for notices, so a turn without notify leaves no mark.
|
||||
- **Logging.** The delivered candidate, the turn's tool set, and the notify call are Session events, so replay rebuilds the turn; the TypeScript and Python SDK expected outputs and a recorded-session snapshot cover them.
|
||||
- **Storage.** Source, parameters, Session mode, trigger state, and run records extend the Host task domain in a new record version with an adjacent migration. A version-1 task reads as a time task, is not rewritten until it changes, and gets no invented records. Run records have their own count and age bounds, separate from delivery records.
|
||||
- **Provider configuration.** Polling intervals and request budgets are validated Config fields of each provider in cordis.yml.
|
||||
|
||||
### Product scope
|
||||
|
||||
- No new entry point and no page redesign: scheduled and event-triggered tasks share the automation page.
|
||||
- The task list adds a source-type filter next to the status filter.
|
||||
- Task details add the source with its parameters, read-only, and the Session mode, editable.
|
||||
- A task's Records tab also lists filtered and failed evaluations.
|
||||
- Notifications reuse the Session unread mark and action cards; the desktop app adds a system notification.
|
||||
|
||||
### Event triggers as infrastructure
|
||||
|
||||
The trigger layer is shared infrastructure for DSH, not a feature of the automation page. A feature that watches something and hands changes to an agent becomes a task on this layer instead of building its own polling, state, records, and notifications.
|
||||
|
||||
- **DSH's own state as sources**: a Session turn ending in an error, a background job settling, and, once memory lands, a memory consolidation pass finishing or memory entries changing.
|
||||
- **Features on those sources**: plugin recommendations, where a workflow the user repeats by hand shows up after consolidation and the agent proposes a plugin on an action card, and task recommendations, where a review of the day's Sessions proposes follow-up work. Each is a template task with a DSH source, an instruction, and the same notify step. The plugin recommendations package, which proposes cards from conversation turns today, can move onto such a source once consolidation lands.
|
||||
- **What every consumer keeps**: change detection and condition matching in code, run records, Session modes, read-only event turns, and notify as the only way a task reaches the user.
|
||||
|
||||
### Delivery order
|
||||
|
||||
1. Task–Session decoupling with the three Session modes.
|
||||
2. The trigger layer with time as its first provider and the task-domain migration; existing scheduled tasks behave as before.
|
||||
3. The GitHub provider for pull request checks, reviews, and comments; the event-turn tool set and `automation_notify`; action cards leave experimental status or ship with the automation bundle.
|
||||
4. Mail and IM providers and official templates, then DSH's own sources.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Judge every observation in a resident Session.** The model reads each poll and replies with a silence marker when nothing changed. It lost because each observation costs a full turn over the Session's context, polls spaced beyond the prefix cache lifetime read that context uncached, and silence depends on the model following an instruction while it holds a messaging tool. Here code drops unchanged and unmatched observations, and only a notify call reaches the user.
|
||||
|
||||
**Judge in the Session that created the task.** That conversation already holds the code and its history, so the instruction would need no extra facts. It lost because most candidates produce no notice, and each of them would add a turn over the conversation's whole context, read uncached once the prefix cache has expired, and queue behind or interrupt the user's own work there. Only accepted work goes to that Session.
|
||||
|
||||
**Let the agent choose the Session for each event.** The choice could differ from one event to the next, so the user could not predict where a notification appears, and deciding whether the history is needed requires reading it first.
|
||||
|
||||
**Condition scripts per task.** A task would carry a script that fetches and decides. It lost because the script runs with the user's credentials without the review a plugin installation gets, and each task would carry its own fetch logic instead of sharing a provider.
|
||||
|
||||
**Notify from code when code can decide.** A failed check could notify without a model. It lost because the notification would carry only the raw status. With one path, the agent reads the logs first and the notification states the cause; change-only delivery keeps these turns rare.
|
||||
|
||||
**One model call outside any Session.** A single call per event costs less and leaves no Session record. It lost because the call cannot read files or logs before deciding, and acting on its result needs a Session anyway.
|
||||
|
||||
**Event turns with the Session's permissions.** Acting directly would save one click. It lost because event content comes from other people, and a read-only turn keeps that text from driving edits, pushes, or messages before the user accepts.
|
||||
|
||||
**A read-only sandbox for event turns.** It lost because sandbox read-only mode limits only the filesystem, and a turn in it can still push or send requests.
|
||||
|
||||
**An end tool and a check on how the turn ended.** A second tool would record why a turn produced no notice, and the trigger would wait for that outcome. It lost because nothing acts on that reason: delivery ends the trigger's work, and a turn without notify is already silent.
|
||||
|
||||
**Parameter forms on the automation page.** Each provider would need its own form, validation, and display. Parameters written by the model and shown read-only cover creation and change through conversation, and the provider schema keeps a generated form possible later.
|
||||
|
||||
**A separate notification entry.** An inbox page or tab would separate the notification from the Session that holds its context. The unread mark and the system notification lead to that Session, and the automation page keeps the records.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Existing scheduled tasks behave the same after time becomes a provider: the same due times, the same catch-up rule, and the same delivery into the Session.
|
||||
- A version-1 task loads as a time task without being rewritten and without invented records.
|
||||
- A GitHub task on a pull request's checks delivers one candidate when a check turns from passing to failing, none while it stays failing, and another after a pass followed by a new failure.
|
||||
- One push that fails three checks produces one candidate.
|
||||
- An observation without a change writes no record and starts no turn; a change that fails the conditions writes a filtered record and starts no turn; consecutive failures with the same cause write one record with a count.
|
||||
- A run record ends at delivery: it records the candidate, not what the turn did.
|
||||
- An event turn is offered no shell, file-write, web-fetch, or MCP tool, and its tool set appears in the Session log; after the user accepts its card, the next turn runs with the Session's permissions.
|
||||
- A notify call produces the card, the unread mark, and the desktop system notification together.
|
||||
- An event turn without a notify call leaves no unread mark and sends no system notification.
|
||||
- An event task's read-only turns run in the task's own Session, and accepting its card sends the proposed action to the Session the task was created in.
|
||||
- Each Session mode delivers as defined: a specified Session receives every trigger, a new Session opens per trigger, or one Session opens on the first trigger and is reused.
|
||||
- Replaying the Session log rebuilds an event turn's candidate, tool set, and notify call.
|
||||
- After a restart, a task compares the current source state with its stored state once and does not replay intermediate changes.
|
||||
- The task list filters by source type, task details show source parameters read-only, and the Session mode can be changed.
|
||||
|
||||
## Risks
|
||||
|
||||
- The read-only turn sees the instruction, the event, and the task's earlier decisions, not the conversation the task came from. Facts the judgment needs from that conversation must be written into the instruction when the task is created; a user who switches the task to its original Session gets the history back at the cost of reading the long context on each delivery.
|
||||
- A notification appears in the task's own Session, not in the conversation that created the task; accepting its card moves the work to that conversation.
|
||||
- The agent may notify too often or stay silent when it should not. Accepted and dismissed cards show over-notification, but a missed notice leaves no trace in the run records, which end at delivery.
|
||||
- Source parameters cannot be edited by hand; every change goes through the model.
|
||||
- A fix on an event waits for the user to accept the card instead of running unattended.
|
||||
- A source without a provider plugin cannot be watched.
|
||||
- Tasks run only while the Host runs: nothing runs while the app is closed or the machine sleeps.
|
||||
- Event tasks depend on action cards, which are experimental and mounted only by the Personalized recommendations bundle today.
|
||||
- The read-only tool set limits judgment to what files and the provider's read operations show; for a source whose provider offers no read operations, the agent judges from the event alone.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Agent Note: 事件触发的自动化任务
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-10-29-event-triggered-automation-tasks.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
目前自动化任务只能按时间规则运行。Host 保存每条规则,每到一个到期时间,就把任务的指令作为一条后续消息排进任务的原会话([Host 拥有的定时消息](../../implemented/architecture/2026-09-16-host-schedule-storage.zh.md));模型通过按 preset 提供的工具创建和修改任务([按 preset 提供的 Schedule 工具](../../implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md))。有些工作应当在别处发生变化时开始,例如 PR(Pull Request)的检查失败、评审人要求修改、收到一条消息,这些都无法建成任务,用户只能在多个会话之间手动查看这些来源。
|
||||
|
||||
响应变化会带来两个时间规则没有的问题。
|
||||
|
||||
- **成本和噪音。** 对来源的大多数观察里,没有值得花一个模型轮次处理的内容。如果每次观察都交给模型,并依靠模型回复一个标记来保持安静,每次轮询都要花一个完整轮次;手里有发消息工具的模型仍然会报告“没有新情况”。
|
||||
- **不可信的输入。** 事件内容由其他人写成:评论、评审、消息。由这类内容启动的轮次,在用户看到之前不能修改、推送或发送任何东西。
|
||||
|
||||
## 提案
|
||||
|
||||
自动化任务改为由指令、带参数的来源和会话模式组成。时间是多种来源中的一种,因此定时任务就是来源为时间的自动化任务。每次观察依次经过三层,只有最后一层调用模型:来源事件经过触发器过滤成为候选事件(candidate),候选事件投递到会话,由模型在会话中判断,只有调用通知工具才会为用户产生一条通知(notice)。本提案部分取代上面链接的两篇笔记:任务不再绑定原会话,任务记录也不再只表示收件箱投递。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Task["Task: instruction, source parameters, Session mode<br/>written by the model, shown read-only"]
|
||||
Prov["Source providers: plugins, code<br/>time, GitHub; later mail, IM, memory"]
|
||||
Trig["Event trigger: shared code, state per task<br/>compare with stored state, match parameters, merge"]
|
||||
Rec[("Run records")]
|
||||
Src{"Source"}
|
||||
Exec["Run the instruction in the target Session<br/>with its permissions"]
|
||||
RO["Read-only turn in the task's own Session<br/>the model judges the candidate"]
|
||||
Note["Notice: action card, unread mark,<br/>system notification"]
|
||||
Quiet["No notice"]
|
||||
Acc["User accepts: the action runs in the Session<br/>that created the task, with its permissions"]
|
||||
Task --> Trig
|
||||
Prov -- "source events" --> Trig
|
||||
Trig -- "each change or failure" --> Rec
|
||||
Trig -- candidate --> Src
|
||||
Src -- time --> Exec
|
||||
Src -- "other sources" --> RO
|
||||
RO -- notify --> Note
|
||||
RO -- "no notify call" --> Quiet
|
||||
Note --> Acc
|
||||
```
|
||||
|
||||
| 层 | 判断什么 | 是否调用模型 | 输出 |
|
||||
|---|---|---|---|
|
||||
| 来源提供方(source provider) | 来源里发生了什么变化 | 否 | 事件 |
|
||||
| 事件触发器(event trigger) | 这个变化与本任务是否相关 | 否 | 一条运行记录;一个候选事件,或者没有 |
|
||||
| agent(智能体) | 用户是否需要处理,以及建议怎么处理 | 只对已投递的候选事件 | 通过通知工具产生通知,或者没有 |
|
||||
|
||||
### 分层
|
||||
|
||||
**来源提供方**是代码,以插件形式提供;它们提供事件来源,与模型、SSH 或凭据的提供方无关。一个来源提供方负责一类来源:它轮询或订阅来源,把变化报告为事件(包含对象、一行摘要、详细内容和链接),并声明任务可设置参数的 JSON schema。同类的所有任务共用同一个来源提供方。时间是第一个来源提供方,现有的定时规则成为它的参数。任何已安装的插件都可以注册来源提供方,包括在创造模式中写出的插件;任务只能指定已注册的来源提供方。
|
||||
|
||||
**事件触发器**是共享的代码,按来源类型和版本注册;每个任务只保存自己的参数和上次观察到的来源状态。每次观察时,它把新状态与保存的状态比较;没有变化的观察只更新最近检查时间。检查从通过变为失败算一次变化,持续失败不算,通过之后再次失败又算一次变化。对一次变化,触发器先匹配任务的条件(参数中的字段,例如结论为失败、作者是用户本人、评论者不是 bot),再在来源提供方的合并窗口内合并同一对象的多次变化(一次推送导致三项检查失败,只算一个事件),然后把新状态和任务一起保存,向会话投递一个候选事件,或者丢弃这次变化。每次变化和每次失败都写一条运行记录,列在任务的运行记录页签中,与现有的投递记录并列:已投递候选事件、已过滤(附原因)或失败。同一原因的连续失败共用一条记录并计数。投递之后触发器的工作就结束了,不等待该轮次,也不读取回执。每个任务用一个队列排定自己的判定顺序。
|
||||
|
||||
**agent** 在目标会话中以一条消息收到候选事件,消息附带以用户原话写成的任务指令。轮次能做什么取决于来源。
|
||||
|
||||
- **时间**:轮次按会话的权限执行指令,回复就是结果,与现在的定时任务一致。
|
||||
- **其他来源**:轮次使用“机制”一节所述的只读工具集。它可以读文件和来源的状态,例如检查日志和 PR diff,但没有修改、推送或发送的工具。新增一个工具 **通知**(`automation_notify`),接收标题、摘要和建议的操作,产生一条通知:会话中出现带有建议操作的[操作卡片](../../../../packages/experimental/action-cards/README.zh.md)、会话显示未读标记、桌面端发送系统通知。是否调用由模型决定。没有调用通知的轮次不产生通知;没有结束工具,也不检查轮次如何结束。用户接受卡片后,Host 向处理该任务的会话(见“会话”一节)发送一条后续消息:消息写明任务和事件,并把建议的操作作为自动化提出的建议引用,而不是作为用户的话;该轮次按该会话的权限执行。
|
||||
|
||||
### 会话
|
||||
|
||||
任务按三种模式之一投递到目标会话:指定的会话、每次触发新建会话、首次触发时新建会话并在之后的触发中复用。定时任务沿用现在的目标,即创建它的会话。事件任务把判断和处理分开。只读轮次在任务自己的会话中运行,首次触发时新建,之后复用,因为它需要的是指令、本次事件和该任务之前的判断,而不是任务来源会话的长历史。模型在会话中创建任务时,把判断需要的事实写进指令,例如哪项检查已知不稳定。用户接受卡片后,建议的操作发往创建任务的会话,代码和相关历史都在那里。自动化页的“新建”本来就在一个新会话中创建任务,之后由该会话处理;从官方模板启用的任务(后续步骤加入)在自己的会话中继续执行。用户可以在任务上修改模式。
|
||||
|
||||
Host 停止期间不运行任何任务。重启后,每个任务把当前来源状态与保存的状态比较一次,不重放中间的变化。时间沿用现有规则:周期任务只补跑最近一次错过的时间点。
|
||||
|
||||
### 配置
|
||||
|
||||
配置分两层。来源提供方的代码决定能观察什么、如何观察;任务参数决定一个任务观察什么。模型根据用户原话,通过任务工具写入参数,由来源提供方的 schema 校验。任务不携带脚本或其他代码,因此要观察新的东西,就修改或安装来源提供方插件。自动化页以只读方式显示来源参数,参数通过对话修改;时间编辑器保留。
|
||||
|
||||
### 机制
|
||||
|
||||
- **事件轮次的工具集。** 沙箱的只读模式只限制文件系统,挡不住推送或外发请求。因此事件轮次使用固定的工具集,而不是会话的工具集:读文件和搜索、来源提供方的读取操作,以及 `automation_notify`。没有 shell、写文件、网页抓取和 MCP 工具。工具集记录在会话日志中;轮次进行中排队的用户消息,在该轮次结束后作为普通轮次执行。
|
||||
- **通知卡片。** 自动化包注册一种操作卡片类型,`automation_notify` 通过操作卡片服务创建这类卡片;该工具只在事件轮次中提供,因此调用前不需要加载 Skill。接受卡片时执行该类型的处理器,由处理器通过 Host 排入后续消息,所以处理任务的会话不必处于打开状态。同一会话中,新卡片会替换尚未处理的旧卡片,与操作卡片服务现在的行为一致;旧卡片在该会话的历史中,新的轮次能看到它,并把仍未处理的内容带到新卡片里。被拒绝的卡片会把错误返回给该轮次。
|
||||
- **未读标记。** 客户端根据轮次完成推导未读标记。任务自己的会话标记为自动化会话,对这类会话,客户端只为通知显示未读标记,因此没有调用通知的轮次不留下标记。
|
||||
- **日志。** 已投递的候选事件、轮次的工具集和通知调用都是会话事件,回放可以重建该轮次;TypeScript 和 Python SDK 的期望输出以及一个录制会话快照覆盖这些内容。
|
||||
- **存储。** 来源、参数、会话模式、触发器状态和运行记录以新的记录版本扩展 Host 任务域,并提供相邻迁移。版本 1 的任务按时间任务读取,在发生修改前不重写,也不补造记录。运行记录有自己的数量和时长上限,与投递记录分开。
|
||||
- **来源提供方配置。** 轮询间隔和请求额度是每个来源提供方在 cordis.yml 中经过校验的 Config 字段。
|
||||
|
||||
### 产品范围
|
||||
|
||||
- 不新增入口,不重做页面:定时任务和事件触发的任务共用自动化页。
|
||||
- 任务列表在状态筛选旁增加来源类型筛选。
|
||||
- 任务详情增加来源及其参数(只读)和会话模式(可修改)。
|
||||
- 任务运行记录同时列出已过滤和失败的判定。
|
||||
- 通知复用会话的未读标记和操作卡片;桌面端新增系统通知。
|
||||
|
||||
### 事件触发作为基础设施
|
||||
|
||||
触发层是 DSH 共用的基础设施,而不是自动化页的一项功能。需要观察某样东西并把变化交给 agent 的功能,都作为这一层上的任务实现,不再各自实现轮询、状态、记录和通知。
|
||||
|
||||
- **DSH 自身状态作为来源**:会话轮次以错误结束、后台作业结束;memory 落地后,还包括一次 memory 整理完成,或 memory 条目发生变化。
|
||||
- **基于这些来源的功能**:插件推荐,即 memory 整理后发现用户反复手动执行的流程,由 agent 在操作卡片上推荐插件;任务推荐,即回顾当天的会话,提出后续要做的事。每一项都是一个模板任务,由一个 DSH 来源、一条指令和同样的通知步骤组成。插件推荐包现在从对话轮次中提出卡片,memory 整理落地后可以改为基于这类来源。
|
||||
- **每个使用方都保留的规则**:变化检测和条件匹配由代码完成、运行记录、会话模式、只读的事件轮次,以及只有通知能让任务触达用户。
|
||||
|
||||
### 交付顺序
|
||||
|
||||
1. 任务与会话解耦,支持三种会话模式。
|
||||
2. 上线触发层和任务域迁移,以时间作为第一个来源提供方;现有定时任务行为不变。
|
||||
3. GitHub 来源提供方,覆盖 PR 的检查、评审和评论;事件轮次工具集和 `automation_notify`;操作卡片结束实验状态,或随自动化 bundle 一起提供。
|
||||
4. 邮件和 IM 来源提供方以及官方模板,然后是 DSH 自身的来源。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在常驻会话中判断每次观察。** 模型读取每次轮询的结果,没有变化时回复一个静默标记。没有采用,原因是:每次观察都要在会话的完整上下文上花一个轮次;轮询间隔超过前缀缓存的有效期时,每次都要在没有缓存的情况下读取整个上下文;而且模型手里有发消息工具时,能否保持安静取决于它是否遵守指令。本方案由代码丢弃没有变化和不匹配的观察,只有调用通知才会触达用户。
|
||||
|
||||
**在创建任务的会话中判断。** 该会话已经有代码和相关历史,指令里不需要额外写事实。没有采用,原因是大多数候选事件不产生通知,每一次都会在该会话的完整上下文上增加一个轮次,前缀缓存过期后要在没有缓存的情况下读取整个上下文,用户正在那里工作时还要排队或打断。只有被接受的工作才会进入该会话。
|
||||
|
||||
**由 agent 为每个事件选择会话。** 每次的选择可能不同,用户无法预料通知出现在哪里;而且要判断是否需要历史,得先读取历史。
|
||||
|
||||
**每个任务自带条件脚本。** 任务携带一个脚本,由脚本获取数据并作出判断。没有采用,原因是脚本以用户的凭据运行,却没有经过安装插件时的审查;而且每个任务都要自带获取逻辑,无法共用来源提供方。
|
||||
|
||||
**代码能判断时由代码直接通知。** 检查失败可以不经模型直接通知。没有采用,原因是这样的通知只包含原始状态。只保留一条路径时,agent 会先读日志,通知中写明失败原因;只在变化时投递,这类轮次很少。
|
||||
|
||||
**在会话之外调用一次模型。** 每个事件调用一次模型,成本更低,也不留下会话记录。没有采用,原因是这次调用在判断前无法读取文件或日志,而且要根据结果采取行动仍然需要会话。
|
||||
|
||||
**事件轮次沿用会话的权限。** 直接行动可以省去一次点击。没有采用,原因是事件内容来自其他人,只读轮次能防止这些文字在用户接受之前驱动修改、推送或发消息。
|
||||
|
||||
**事件轮次使用只读沙箱。** 没有采用,原因是沙箱的只读模式只限制文件系统,处于该模式的轮次仍然可以推送或发出请求。
|
||||
|
||||
**结束工具和对轮次结束方式的检查。** 第二个工具可以记录某一轮为什么没有产生通知,触发器则要等待这个结果。没有采用,原因是没有任何环节会使用这个原因:投递之后触发器的工作就结束了,而没有调用通知的轮次本来就是静默的。
|
||||
|
||||
**在自动化页提供参数表单。** 每个来源提供方都需要各自的表单、校验和展示。由模型写入参数并以只读方式展示,已经覆盖通过对话创建和修改;来源提供方的 schema 让以后生成表单仍然可行。
|
||||
|
||||
**单独的通知入口。** 收件箱页面或标签页会把通知与持有其上下文的会话分开。未读标记和系统通知都指向该会话,自动化页保留记录。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 时间改为来源提供方后,现有定时任务行为不变:到期时间相同、补跑规则相同、投递到会话的方式相同。
|
||||
- 版本 1 的任务按时间任务加载,不重写,也不补造记录。
|
||||
- 观察 PR 检查的 GitHub 任务,在检查从通过变为失败时投递一个候选事件,持续失败期间不再投递,通过之后再次失败时再投递一个。
|
||||
- 一次推送导致三项检查失败,只产生一个候选事件。
|
||||
- 没有变化的观察不写记录,也不启动轮次;不满足条件的变化写一条已过滤记录,不启动轮次;同一原因的连续失败只写一条带计数的记录。
|
||||
- 运行记录到投递为止:记录候选事件,不记录该轮次做了什么。
|
||||
- 事件轮次不提供 shell、写文件、网页抓取或 MCP 工具,其工具集出现在会话日志中;用户接受卡片后,下一轮次按会话的权限执行。
|
||||
- 一次通知调用同时产生卡片、未读标记和桌面端系统通知。
|
||||
- 没有调用通知的事件轮次不留下未读标记,也不发送系统通知。
|
||||
- 事件任务的只读轮次在任务自己的会话中运行;接受卡片后,建议的操作发往创建任务的会话。
|
||||
- 每种会话模式按定义投递:指定的会话接收每次触发,或每次触发新建一个会话,或首次触发时新建一个会话并在之后复用。
|
||||
- 回放会话日志可以重建事件轮次的候选事件、工具集和通知调用。
|
||||
- 重启后,任务把当前来源状态与保存的状态比较一次,不重放中间的变化。
|
||||
- 任务列表可以按来源类型筛选,任务详情以只读方式显示来源参数,会话模式可以修改。
|
||||
|
||||
## 风险
|
||||
|
||||
- 只读轮次看到的是指令、本次事件和该任务之前的判断,看不到任务来源的会话。判断需要的、来自那个会话的事实,必须在创建任务时写进指令;用户把任务改成投回原会话,就能拿回历史,代价是每次投递都要读取很长的上下文。
|
||||
- 通知出现在任务自己的会话里,而不是创建任务的对话里;接受卡片后,工作转到那个对话。
|
||||
- agent 可能通知过于频繁,或者在应该提醒时保持静默。被接受和被忽略的卡片能看出通知过多,但漏掉的通知不会在运行记录中留下痕迹,因为运行记录到投递为止。
|
||||
- 来源参数不能手动编辑,每次修改都要经过模型。
|
||||
- 针对事件的修复要等用户接受卡片,不会在无人值守时执行。
|
||||
- 没有来源提供方插件的来源无法观察。
|
||||
- 任务只在 Host 运行时执行:应用关闭或机器休眠期间不运行任何任务。
|
||||
- 事件任务依赖操作卡片,而它目前是实验包,只由个性化推荐 Bundle 装入。
|
||||
- 只读工具集把判断限制在文件和来源提供方读取操作能看到的内容;来源提供方不提供读取操作时,agent 只能根据事件本身判断。
|
||||
@@ -91,6 +91,8 @@ External packages installed for runtime use or embedded in distributed Host or b
|
||||
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
|
||||
| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) |
|
||||
| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
|
||||
| [`eslint-scope`](https://github.com/eslint/js) | BSD-2-Clause |
|
||||
| [`eslint-visitor-keys`](https://github.com/eslint/js) | Apache-2.0 |
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
| [`exceljs`](https://github.com/exceljs/exceljs) | MIT |
|
||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||
@@ -220,6 +222,7 @@ External packages **directly declared** for development, tests, types, or toolin
|
||||
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/estree`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/hosted-git-info`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
|
||||
@@ -3,3 +3,8 @@
|
||||
|
||||
- id: tool-executor-ptc
|
||||
disabled: false
|
||||
|
||||
# PTC mode exposes the pre-apply_patch tool set, as the Web ptc preset does.
|
||||
- id: tool-fs
|
||||
config:
|
||||
applyPatch: false
|
||||
|
||||
+2
-2
@@ -17,8 +17,8 @@
|
||||
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}}
|
||||
{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}}
|
||||
{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
|
||||
|
||||
@@ -58,11 +58,16 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
{ label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false },
|
||||
{ label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true },
|
||||
{ label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true },
|
||||
// The shipped sdk profile mounts no observation policy: an edit of a file the model never read lands on disk.
|
||||
{ label: 'edits a file the model never read through the shipped profile', envValue: undefined, editorEnabled: false, unreadEdit: true },
|
||||
])('$label', async (scenario) => {
|
||||
const { envValue, editorEnabled } = scenario
|
||||
const ptc = 'ptc' in scenario && scenario.ptc
|
||||
const unreadEdit = 'unreadEdit' in scenario && scenario.unreadEdit
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-smoke-'))
|
||||
const unreadFile = join(root, 'notes.txt')
|
||||
if (unreadEdit) await writeFile(unreadFile, 'alpha\n')
|
||||
const editorPatch = join(root, 'editor.patch.yml')
|
||||
if (editorEnabled) await writeFile(editorPatch, [
|
||||
'- insert:',
|
||||
@@ -78,7 +83,14 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
request.on('end', () => {
|
||||
modelRequests.push(JSON.parse(body) as Record<string, unknown>)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(messagesResponse({ type: 'text', text: 'done' }, 'max_tokens'))
|
||||
if (unreadEdit && modelRequests.length === 1) {
|
||||
response.end(messagesResponse({
|
||||
type: 'tool_use', id: 'unread-edit', name: 'edit',
|
||||
input: { file_path: 'notes.txt', old_string: 'alpha', new_string: 'beta' },
|
||||
}, 'tool_use'))
|
||||
return
|
||||
}
|
||||
response.end(messagesResponse({ type: 'text', text: 'done' }, unreadEdit ? 'end_turn' : 'max_tokens'))
|
||||
})
|
||||
})
|
||||
await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
|
||||
@@ -164,21 +176,27 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
sessionId: 'main',
|
||||
event: {
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'max-tokens' } },
|
||||
data: { reason: { kind: unreadEdit ? 'completed' : 'max-tokens' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
if (unreadEdit) {
|
||||
expect(modelRequests).toHaveLength(2)
|
||||
expect(await readFile(unreadFile, 'utf8')).toBe('beta\n')
|
||||
}
|
||||
expect(modelRequests[0]?.tools).toEqual(expect.any(Array))
|
||||
const tools = modelRequests[0]?.tools as { name?: string }[]
|
||||
const toolNames = tools.map(tool => tool.name)
|
||||
expect(modelRequests[0]?.output_config).toEqual({ effort: 'max' })
|
||||
expect(modelRequests[0]?.max_tokens).toBe(1234)
|
||||
const expectedTools = ['read', 'write', 'edit', 'web_fetch', 'web_search', 'subagent', 'subagent_fork',
|
||||
const expectedTools = ['read', 'write', 'edit', 'apply_patch', 'web_fetch', 'web_search', 'subagent', 'subagent_fork',
|
||||
'present', 'ask_user_question', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'plugin_manager']
|
||||
const requestText = JSON.stringify(modelRequests[0])
|
||||
if (ptc) {
|
||||
expect(toolNames).toEqual(['run_code'])
|
||||
for (const tool of expectedTools) expect(requestText.includes(`${tool}:`), tool).toBe(true)
|
||||
// The PTC overlay keeps the pre-apply_patch tool set, as the Web ptc preset does.
|
||||
for (const tool of expectedTools.filter(tool => tool !== 'apply_patch')) expect(requestText.includes(`${tool}:`), tool).toBe(true)
|
||||
expect(requestText.includes('apply_patch:')).toBe(false)
|
||||
} else {
|
||||
expect(toolNames).toEqual(expect.arrayContaining(expectedTools))
|
||||
expect(toolNames.includes('str_replace_editor')).toBe(editorEnabled)
|
||||
|
||||
@@ -300,7 +300,7 @@ describe('the shipped Web composition', () => {
|
||||
// excluded for the reason the TUI composition e2e excludes them — they
|
||||
// depend on ripgrep being present on the machine.
|
||||
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'apply_patch', 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image',
|
||||
'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view',
|
||||
'send_message', 'skill',
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'claim',
|
||||
})
|
||||
await action.getByText('In progress', { exact: true }).waitFor()
|
||||
await action.getByText('Owner: Lead', { exact: true }).waitFor()
|
||||
await action.getByText(`Owner: ${agent.id}`, { exact: true }).waitFor()
|
||||
expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0)
|
||||
expect(await action.locator('input, select, textarea').count()).toBe(0)
|
||||
|
||||
@@ -162,6 +162,10 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
})
|
||||
|
||||
it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => {
|
||||
onTestFailed(async () => {
|
||||
await saveFailureShot(page, 'web-e2e-agent-team-hover')
|
||||
console.error('Agent Team hover diagnostics', JSON.stringify({ errors: tripwire.pageErrors, warnings: tripwire.warnings }))
|
||||
})
|
||||
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Task board', exact: true })
|
||||
const panel = page.getByRole('dialog', { name: 'Task board', exact: true })
|
||||
await trigger.hover()
|
||||
|
||||
@@ -119,13 +119,16 @@ describe.skipIf(MODE === 'record')('web e2e: cold Auto-review denial', () => {
|
||||
await ledger.locator('table[data-scroll-ready="true"]').waitFor({ timeout: 15_000 })
|
||||
const nativeRecord = ledger.locator('tr[data-kind="tool"]').filter({ hasText: 'mystery' })
|
||||
const innerRecord = ledger.locator('tr[data-kind="subtool"]').filter({ hasText: 'bash' })
|
||||
await expect.poll(() => nativeRecord.textContent()).toContain('AUTO_REVIEW_DENIED')
|
||||
await expect.poll(() => innerRecord.textContent()).toContain('AUTO_REVIEW_DENIED')
|
||||
await expect.poll(() => nativeRecord.textContent()).toContain('Error: Auto review rejected tool "mystery"; its body was not executed')
|
||||
await expect.poll(() => innerRecord.textContent()).toContain('Error: Auto review rejected tool "bash"; its body was not executed')
|
||||
await innerRecord.click()
|
||||
await page.getByRole('tab', { name: 'Result', exact: true }).click()
|
||||
await page.getByRole('tabpanel', { name: 'Result' })
|
||||
.getByText('AutoReviewDeniedError: AUTO_REVIEW_DENIED', { exact: true })
|
||||
.waitFor({ state: 'visible' })
|
||||
for (const tab of ['Summary', 'Result']) {
|
||||
await page.getByRole('tab', { name: tab, exact: true }).click()
|
||||
const panel = page.getByRole('tabpanel', { name: tab })
|
||||
await panel.getByText('Error: Auto review rejected tool "bash"; its body was not executed', { exact: true })
|
||||
.waitFor({ state: 'visible' })
|
||||
expect(await panel.getByText('AutoReviewDeniedError: AUTO_REVIEW_DENIED', { exact: true }).count()).toBe(0)
|
||||
}
|
||||
await captureAutoReviewState(page, 'deny-trajectory')
|
||||
const trajectory = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Browser geometry for the input card across Chat and Trajectory. The browser
|
||||
// must expose layout-consuming scrollbars, and an uncompensated control keeps
|
||||
// equal rectangles from passing vacuously.
|
||||
// Browser geometry for the input card across Chat and Trajectory, plus the
|
||||
// docked seat's stacking rank, fixed overlays, floor position, and statistics
|
||||
// row. The browser must expose layout-consuming scrollbars, and an
|
||||
// uncompensated control keeps equal rectangles from passing vacuously.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-tab-geometry', import.meta.url))
|
||||
/** Records platform-neutral distances between the two tabs' card rectangles. */
|
||||
@@ -158,6 +159,28 @@ async function compareTabs(page: Page): Promise<TabComparison> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one measurement with an extra stylesheet in the page, then remove it.
|
||||
* @param page - the page under test.
|
||||
* @param id - the stylesheet element id, unique per rule set.
|
||||
* @param css - rules applied only for the measurement.
|
||||
* @param measure - the measurement taken while the rules apply.
|
||||
* @returns the measurement's result.
|
||||
*/
|
||||
async function withPageStyle<T>(page: Page, id: string, css: string, measure: () => Promise<T>): Promise<T> {
|
||||
await page.evaluate(({ styleId, text }) => {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = text
|
||||
document.head.append(style)
|
||||
}, { styleId: id, text: css })
|
||||
try {
|
||||
return await measure()
|
||||
} finally {
|
||||
await page.evaluate((styleId) => { document.getElementById(styleId)?.remove() }, id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the uncompensated cascade in the page for one measurement, then lift it:
|
||||
* the overlay seat's `right` compensation dropped to 0, so it measures the
|
||||
@@ -166,17 +189,7 @@ async function compareTabs(page: Page): Promise<TabComparison> {
|
||||
* @returns the comparison as the column lays out without the compensation.
|
||||
*/
|
||||
async function compareTabsWithoutCompensation(page: Page): Promise<TabComparison> {
|
||||
await page.evaluate(({ id, css }) => {
|
||||
const style = document.createElement('style')
|
||||
style.id = id
|
||||
style.textContent = css
|
||||
document.head.append(style)
|
||||
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
|
||||
try {
|
||||
return await compareTabs(page)
|
||||
} finally {
|
||||
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
|
||||
}
|
||||
return await withPageStyle(page, CONTROL_STYLE_ID, CONTROL_CSS, () => compareTabs(page))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,6 +358,110 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the docked composer at one stacking rank while its command menu opens', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-seat-rank'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const host = page.locator('[data-conversation-scroll]')
|
||||
const seat = page.locator('[data-conversation-scroll] > [data-composer-seat]')
|
||||
const rank = (): Promise<number> => seat.evaluate(element => Number(getComputedStyle(element).zIndex))
|
||||
// Scroll anchoring never selects composer content.
|
||||
expect(await seat.evaluate(element => getComputedStyle(element).overflowAnchor)).toBe('none')
|
||||
await host.evaluate((element) => { element.scrollTop = 0 })
|
||||
const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
await backToBottom.waitFor({ timeout: 10_000 })
|
||||
const controlRank = await backToBottom.evaluate((element) => {
|
||||
for (let node: Element | null = element; node !== null; node = node.parentElement) {
|
||||
const zIndex = getComputedStyle(node).zIndex
|
||||
if (zIndex !== 'auto') return Number(zIndex)
|
||||
}
|
||||
throw new Error('back-to-bottom control has no stacking rank')
|
||||
})
|
||||
const resting = await rank()
|
||||
expect(resting).toBeGreaterThan(controlRank)
|
||||
const input = page.locator('[data-composer-input]').last()
|
||||
await writeComposerDraft(page, input, '/')
|
||||
await page.locator('[data-trigger-menu]').waitFor({ timeout: 10_000 })
|
||||
expect(await rank()).toBe(resting)
|
||||
await page.keyboard.press('Escape')
|
||||
await writeComposerDraft(page, input, '')
|
||||
await page.locator('[data-trigger-menu]').waitFor({ state: 'detached', timeout: 10_000 })
|
||||
expect(await rank()).toBe(resting)
|
||||
await host.evaluate((element) => { element.scrollTop = element.scrollHeight })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps fixed-position overlays out of the docked composer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-seat-fixed-overlays'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const seat = page.locator('[data-conversation-scroll] > [data-composer-seat]')
|
||||
// The transform hint makes the seat the containing block for fixed descendants.
|
||||
expect(await seat.evaluate(element => getComputedStyle(element).willChange)).toBe('transform')
|
||||
const fixedInside = (): Promise<number> => seat.evaluate(element => [...element.querySelectorAll('*')]
|
||||
.filter(node => getComputedStyle(node).position === 'fixed').length)
|
||||
const commands = seat.getByRole('button', { name: 'Add files or run commands', exact: true })
|
||||
await commands.hover()
|
||||
const tooltip = page.getByRole('tooltip').filter({ hasText: 'Add files or run commands' })
|
||||
await tooltip.waitFor({ timeout: 10_000 })
|
||||
expect(await tooltip.evaluate(node => node.closest('[data-composer-seat]') === null)).toBe(true)
|
||||
expect(await fixedInside()).toBe(0)
|
||||
// Viewport coordinates still place the bubble above its button.
|
||||
const [bubbleBox, buttonBox] = await Promise.all([tooltip.boundingBox(), commands.boundingBox()])
|
||||
expect(bubbleBox).not.toBeNull()
|
||||
expect(buttonBox).not.toBeNull()
|
||||
if (bubbleBox !== null && buttonBox !== null) {
|
||||
expect(bubbleBox.y + bubbleBox.height).toBeLessThanOrEqual(buttonBox.y + 0.5)
|
||||
expect(bubbleBox.x).toBeLessThan(buttonBox.x + buttonBox.width)
|
||||
expect(bubbleBox.x + bubbleBox.width).toBeGreaterThan(buttonBox.x)
|
||||
}
|
||||
await page.mouse.move(0, 0)
|
||||
const input = page.locator('[data-composer-input]').last()
|
||||
await writeComposerDraft(page, input, '/')
|
||||
await page.locator('[data-trigger-menu]').waitFor({ timeout: 10_000 })
|
||||
expect(await fixedInside()).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await writeComposerDraft(page, input, '')
|
||||
await page.locator('[data-trigger-menu]').waitFor({ state: 'detached', timeout: 10_000 })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('rests the docked composer at the floor when no View fills the column', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-seat-floor'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
// Hides the View subtree, which normally stretches to push the seat down.
|
||||
const gap = await withPageStyle(
|
||||
page,
|
||||
'composer-seat-floor-probe',
|
||||
'[data-conversation-scroll] > :not([data-composer-seat]) { display: none !important; }',
|
||||
() => page.locator('[data-conversation-scroll]').evaluate((element) => {
|
||||
const seat = element.querySelector(':scope > [data-composer-seat]')
|
||||
if (seat === null) throw new Error('no composer seat in the scroll body')
|
||||
const box = element.getBoundingClientRect()
|
||||
return box.top + element.clientTop + element.clientHeight - seat.getBoundingClientRect().bottom
|
||||
}),
|
||||
)
|
||||
expect(Math.abs(gap)).toBeLessThanOrEqual(0.5)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the statistics row height whether or not its readings are present', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-seat-stats-row'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const dock = page.locator('[data-conversation-scroll] > [data-composer-seat] [data-composer-dock]')
|
||||
// Vacuity guard: the seeded usage must produce at least one reading.
|
||||
await expect.poll(() => dock.locator('[data-composer-stat], button[aria-haspopup="dialog"]').count(), { timeout: 10_000 })
|
||||
.toBeGreaterThan(0)
|
||||
const height = (): Promise<number> => dock.evaluate(element => element.getBoundingClientRect().height)
|
||||
const withReadings = await height()
|
||||
const withoutReadings = await withPageStyle(
|
||||
page,
|
||||
'composer-seat-stats-row-probe',
|
||||
'[data-composer-dock] > * { display: none !important; }',
|
||||
height,
|
||||
)
|
||||
expect(Math.abs(withReadings - withoutReadings)).toBeLessThanOrEqual(0.5)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('matches the committed tab geometry golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
|
||||
@@ -330,6 +330,35 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s',
|
||||
expect(await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).count()).toBe(1)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
it('keeps context-menu rows reachable after browser zoom', async () => {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
try {
|
||||
await page.setViewportSize({ width: 1291, height: 480 })
|
||||
await page.locator('[data-row-key="session:context-menu-first"]').click({ button: 'right' })
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.waitFor()
|
||||
// Desktop Chromium caps page scale at three; the short window makes the rows overflow at that scale.
|
||||
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 3 })
|
||||
await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(3)
|
||||
await expect.poll(() => menu.evaluate((node) => {
|
||||
const view = window.visualViewport!
|
||||
const box = node.getBoundingClientRect()
|
||||
return Math.min(box.left - view.offsetLeft, box.top - view.offsetTop,
|
||||
view.offsetLeft + view.width - box.right, view.offsetTop + view.height - box.bottom)
|
||||
})).toBeGreaterThanOrEqual(11.5)
|
||||
const rows = menu.locator('[role="presentation"]').first()
|
||||
expect(await rows.evaluate((node) => {
|
||||
node.scrollTop = node.scrollHeight
|
||||
return node.scrollTop > 0
|
||||
})).toBe(true)
|
||||
} finally {
|
||||
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 })
|
||||
await cdp.detach()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.setViewportSize({ width: 1291, height: 768 })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps original preview open when dismissing its menu and fits both themes at the viewport edge', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'context-menus-preview'))
|
||||
await page.getByRole('button', { name: 'View full image: Original', exact: true }).click()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Context details stay inside the viewport with and without Chat's statistics contribution. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import { chromium, webkit } from 'playwright'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
|
||||
@@ -9,8 +9,11 @@ import { connectFreshWorkspace, newEnglishPage } from './support.ts'
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
|
||||
const NO_CHAT = fileURLToPath(new URL('./fixtures/context-meter-no-chat.patch.yml', import.meta.url))
|
||||
|
||||
describe.skipIf(webSnapshotMode() === 'record')('web e2e: context details placement', () => {
|
||||
it.each([true, false])('keeps details visible across viewport changes (Chat statistics: %s)', async (withStats) => {
|
||||
describe.skipIf(webSnapshotMode() === 'record').each([
|
||||
{ name: 'Chromium', engine: chromium },
|
||||
{ name: 'WebKit', engine: webkit },
|
||||
])('web e2e: context details placement ($name)', ({ engine }) => {
|
||||
it.each([true, false])('keeps details visible while resizing the window (Chat statistics: %s)', async (withStats) => {
|
||||
const fixture = await selectedSessionFixture(FIXTURE, false)
|
||||
const scaffold = await launchWebScaffold({
|
||||
replayFixture: fixture,
|
||||
@@ -19,7 +22,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: context details placem
|
||||
...withStats ? {} : { extraOverlayPath: NO_CHAT },
|
||||
})
|
||||
try {
|
||||
const browser = await chromium.launch()
|
||||
const browser = await engine.launch()
|
||||
try {
|
||||
const page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
@@ -74,6 +77,31 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: context details placem
|
||||
await panel.waitFor()
|
||||
await page.mouse.click(1260, 400)
|
||||
await panel.waitFor({ state: 'hidden' })
|
||||
const panels = [
|
||||
{ trigger, name: 'of context used' },
|
||||
...withStats ? [
|
||||
{ trigger: page.locator('[data-composer-stat="activity"] button'), name: 'Session statistics' },
|
||||
{ trigger: page.locator('[data-composer-stat="usage"] button'), name: 'Token usage' },
|
||||
] : [],
|
||||
]
|
||||
for (const details of panels) {
|
||||
await details.trigger.click()
|
||||
const dialog = page.getByRole('dialog', { name: details.name, exact: true })
|
||||
await dialog.waitFor()
|
||||
for (const width of [1280, 800, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect.poll(async () => {
|
||||
const rect = await dialog.boundingBox()
|
||||
const anchor = await details.trigger.boundingBox()
|
||||
return rect !== null && anchor !== null
|
||||
&& rect.x >= 11 && rect.x + rect.width <= width - 11
|
||||
&& rect.y >= 11 && rect.y + rect.height <= 889
|
||||
&& Math.abs(anchor.y - rect.y - rect.height - 8) <= 1
|
||||
}).toBe(true)
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
|
||||
@@ -54,7 +54,7 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', ()
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByText('DeepSeek', { exact: true }).waitFor()
|
||||
await page.getByRole('group', { name: 'DeepSeek', exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
|
||||
@@ -272,6 +272,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
await observeFocus('Enter model pane')
|
||||
await devicePage.keyboard.press('ArrowDown')
|
||||
const search = devicePage.getByRole('searchbox', { name: '搜索模型…' })
|
||||
expect(await search.evaluate(input => getComputedStyle(input).fontSize)).toBe('16px')
|
||||
await expect.poll(() => search.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Navigate models')
|
||||
await devicePage.keyboard.press('Escape')
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
- button "左右对比" [pressed]
|
||||
- button "自动换行" [pressed]
|
||||
- region "全部 2 个文件":
|
||||
- button "收起 panel.md" [expanded]
|
||||
- text: docs/panel.md 新文件 +2 −0
|
||||
- button "panel.md 的更多操作"
|
||||
- button "选择第 R1 行": "1"
|
||||
- text: "+ # Panel"
|
||||
- button "选择第 R2 行": "2"
|
||||
- text: + Shows the change requests of a Session.
|
||||
- button "收起 panel.ts" [expanded]
|
||||
- text: src/panel.ts +2 −2
|
||||
- button "panel.ts 的更多操作"
|
||||
@@ -62,10 +69,3 @@
|
||||
- button "选择第 R18 行": "18"
|
||||
- text: export const n18 = 18
|
||||
- button "其余未变更的行"
|
||||
- button "收起 panel.md" [expanded]
|
||||
- text: docs/panel.md 新文件 +2 −0
|
||||
- button "panel.md 的更多操作"
|
||||
- button "选择第 R1 行": "1"
|
||||
- text: "+ # Panel"
|
||||
- button "选择第 R2 行": "2"
|
||||
- text: + Shows the change requests of a Session.
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
- button "自动换行" [pressed]
|
||||
- button "将文件树移到右侧" [expanded]
|
||||
- region "全部 2 个文件":
|
||||
- button "收起 panel.md" [expanded]
|
||||
- text: docs/panel.md 新文件 +2 −0
|
||||
- button "panel.md 的更多操作"
|
||||
- button "选择第 R1 行": "1"
|
||||
- text: "+ # Panel"
|
||||
- button "选择第 R2 行": "2"
|
||||
- text: + Shows the change requests of a Session.
|
||||
- button "收起 panel.ts" [expanded]
|
||||
- text: src/panel.ts +2 −2
|
||||
- button "panel.ts 的更多操作"
|
||||
@@ -63,13 +70,6 @@
|
||||
- button "选择第 R18 行": "18"
|
||||
- text: export const n18 = 18
|
||||
- button "其余未变更的行"
|
||||
- button "收起 panel.md" [expanded]
|
||||
- text: docs/panel.md 新文件 +2 −0
|
||||
- button "panel.md 的更多操作"
|
||||
- button "选择第 R1 行": "1"
|
||||
- text: "+ # Panel"
|
||||
- button "选择第 R2 行": "2"
|
||||
- text: + Shows the change requests of a Session.
|
||||
- separator "调整文件树宽度"
|
||||
- navigation "全部 2 个文件":
|
||||
- textbox "筛选文件…": src
|
||||
|
||||
@@ -722,7 +722,7 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an
|
||||
await local.getByText('与 origin/main 比较,包含未提交的修改', { exact: true }).waitFor({ timeout: 20_000 })
|
||||
const files = local.locator('[data-forge-diff-file]')
|
||||
await expect.poll(() => files.evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))), { timeout: 20_000 })
|
||||
.toEqual(['README.md', 'src/app.ts', 'src/extra.ts', 'notes.md'])
|
||||
.toEqual(['src/app.ts', 'src/extra.ts', 'README.md', 'notes.md'])
|
||||
await local.locator('[data-forge-diff-file="notes.md"]').getByText('新文件 · 未提交', { exact: true }).waitFor()
|
||||
const readme = local.locator('[data-forge-diff-file="README.md"]')
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
@@ -1075,8 +1075,9 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre
|
||||
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-file-keys')
|
||||
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
|
||||
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
|
||||
for (const path of ['a.txt', 'b.txt', 'c.txt']) {
|
||||
await writeFile(join(project, path), Array.from({ length: path === 'c.txt' ? 1 : 80 }, (_, line) => `line ${line + 1}\n`).join(''))
|
||||
await mkdir(join(project, 'src'))
|
||||
for (const path of ['src/a.txt', 'src/b.txt', 'README.md']) {
|
||||
await writeFile(join(project, path), Array.from({ length: path === 'README.md' ? 1 : 80 }, (_, line) => `line ${line + 1}\n`).join(''))
|
||||
}
|
||||
const { settled } = await sendPrompt(page, scaffold)
|
||||
await settled
|
||||
@@ -1089,9 +1090,13 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre
|
||||
await page.getByRole('button', { name: '全屏', exact: true }).click()
|
||||
await page.setViewportSize({ width: 1280, height: 500 })
|
||||
const tree = view.getByRole('navigation')
|
||||
const a = view.locator('[data-forge-diff-file="a.txt"]')
|
||||
const b = view.locator('[data-forge-diff-file="b.txt"]')
|
||||
const c = view.locator('[data-forge-diff-file="c.txt"]')
|
||||
const files = view.locator('[data-forge-diff-file]')
|
||||
const paths = await files.evaluateAll(elements => elements.map(element => element.getAttribute('data-forge-diff-file')))
|
||||
expect(paths).toEqual(['src/a.txt', 'src/b.txt', 'README.md'])
|
||||
expect(await tree.locator('button:not([aria-expanded])').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')))).toEqual(paths)
|
||||
const a = view.locator('[data-forge-diff-file="src/a.txt"]')
|
||||
const b = view.locator('[data-forge-diff-file="src/b.txt"]')
|
||||
const c = view.locator('[data-forge-diff-file="README.md"]')
|
||||
await b.getByRole('button', { name: '收起 b.txt', exact: true }).click()
|
||||
await scroll.evaluate((element) => { element.scrollTop = 0 })
|
||||
await scroll.focus()
|
||||
@@ -1102,11 +1107,11 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre
|
||||
await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
|
||||
await scroll.evaluate((element) => { element.scrollTop = 0 })
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'src/b.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
expect(await b.getAttribute('data-expanded')).toBe('')
|
||||
expect(await scroll.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'README.md', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
const lastPosition = await scroll.evaluate(element => element.scrollTop)
|
||||
const lastBox = await c.boundingBox(), viewport = await scroll.boundingBox()
|
||||
if (lastBox === null || viewport === null) throw new Error('The last file and scroll viewport must be rendered')
|
||||
@@ -1115,16 +1120,16 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre
|
||||
await page.keyboard.press('ArrowRight')
|
||||
expect(await scroll.evaluate(element => element.scrollTop)).toBe(lastPosition)
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'src/b.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'src/a.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
await b.evaluate((element) => {
|
||||
const viewport = element.closest('[data-forge-changes-scroll]')!
|
||||
viewport.scrollTop += element.getBoundingClientRect().top - viewport.getBoundingClientRect().top + 100
|
||||
})
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
await tree.getByRole('button', { name: 'a.txt', exact: true }).click()
|
||||
await expect.poll(() => tree.getByRole('button', { name: 'README.md', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
await tree.getByRole('button', { name: 'src/a.txt', exact: true }).click()
|
||||
await a.getByRole('button', { name: '选择第 R1 行', exact: true }).click()
|
||||
await a.getByRole('button', { name: '添加评论', exact: true }).click()
|
||||
const input = a.getByRole('textbox', { name: '留下评论…', exact: true })
|
||||
@@ -1133,7 +1138,7 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre
|
||||
expect(await input.evaluate(element => (element as HTMLTextAreaElement).selectionStart)).toBe(11)
|
||||
await input.press('ArrowRight')
|
||||
expect(await input.inputValue()).toBe('keep editing')
|
||||
expect(await tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
expect(await tree.getByRole('button', { name: 'src/a.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
expect(await input.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
|
||||
@@ -45,9 +45,9 @@ class HistoryDeliveryGate {
|
||||
return
|
||||
}
|
||||
const control = endpoint === 'session/control' && frame.type === 'item'
|
||||
? frame.value as { type: string; sessionId?: string; key?: string; value?: InboxState } : undefined
|
||||
const inbox = control?.type === 'projection' && control.sessionId === SESSION_ID && control.key === 'inbox'
|
||||
? control.value : undefined
|
||||
? frame.value as { type: string; sessionId?: string; changes?: readonly { key: string; val: unknown }[] } : undefined
|
||||
const inbox = control?.type === 'projection' && control.sessionId === SESSION_ID
|
||||
? control.changes?.find(change => change.key === 'inbox')?.val as InboxState | undefined : undefined
|
||||
const follow = endpoint === 'session/follow' && frame.type === 'item'
|
||||
? frame.value as { type: string; event?: SessionEvent } : undefined
|
||||
this.buffered.push({ stream: frame.streamId, send: () => { socket.send(message) },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Locator } from 'playwright'
|
||||
import { chromium, webkit, type Locator } from 'playwright'
|
||||
import sharp from 'sharp'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, webSnapshotMode } from './scaffold.ts'
|
||||
@@ -10,6 +10,44 @@ import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSessi
|
||||
const expected = fileURLToPath(new URL('./expected/menu-material', import.meta.url))
|
||||
const seed = new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)
|
||||
|
||||
it('keeps the view options menu anchored and dismissible through WebKit window resizing', async () => {
|
||||
const scaffold = await launchWebScaffold({})
|
||||
onTestFinished(() => scaffold.close())
|
||||
const browser = await webkit.launch()
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const anchor = page.getByRole('button', { name: 'View options', exact: true })
|
||||
await anchor.click()
|
||||
const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Workspace Tree', exact: true }) })
|
||||
await menu.waitFor()
|
||||
for (const viewport of [{ width: 1440, height: 1000 }, { width: 1200, height: 700 }]) {
|
||||
await page.setViewportSize(viewport)
|
||||
await expect.poll(async () => {
|
||||
const trigger = await anchor.boundingBox()
|
||||
const panel = await menu.boundingBox()
|
||||
if (trigger === null || panel === null) return null
|
||||
return {
|
||||
// The menu can be wider than the sidebar, so its left margin takes precedence over end alignment.
|
||||
horizontalOffset: Math.round(panel.x - Math.max(12, trigger.x + trigger.width - panel.width)),
|
||||
topGap: Math.round(panel.y - trigger.y - trigger.height),
|
||||
insideViewport: panel.x >= 12 && panel.y >= 12
|
||||
&& panel.x + panel.width <= viewport.width - 12
|
||||
&& panel.y + panel.height <= viewport.height - 12,
|
||||
}
|
||||
}).toEqual({ horizontalOffset: 0, topGap: 4, insideViewport: true })
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
await menu.waitFor({ state: 'hidden' })
|
||||
await anchor.click()
|
||||
await menu.waitFor()
|
||||
await page.mouse.click(800, 500)
|
||||
await menu.waitFor({ state: 'hidden' })
|
||||
await anchor.click()
|
||||
await page.getByRole('menuitem', { name: 'In one list', exact: true }).click()
|
||||
await menu.waitFor({ state: 'hidden' })
|
||||
})
|
||||
|
||||
async function material(menu: Locator) {
|
||||
return menu.evaluate((node) => {
|
||||
const style = getComputedStyle(node)
|
||||
|
||||
@@ -22,7 +22,9 @@ const PROVIDERS = [
|
||||
].map((provider, providerIndex) => ({
|
||||
...provider,
|
||||
models: Array.from({ length: 7 }, (_, index) => providerIndex === 0 && index === 0
|
||||
? { id: 'origin-large', name: 'Origin Large' }
|
||||
? { id: 'origin-large', name: 'Origin Large', reasoningEfforts: {
|
||||
off: null, minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max',
|
||||
} }
|
||||
: { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }),
|
||||
}))
|
||||
const EXPECTED_GROUPS = [
|
||||
@@ -287,4 +289,141 @@ describe.skipIf(webSnapshotMode() === 'record').each([
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
it.runIf(engine === chromium)('keeps the complete model card inside the zoomed visual viewport', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-model-menu-zoom'))
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
try {
|
||||
await page.setViewportSize({ width: 402, height: 874 })
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 })
|
||||
for (const height of [874, 500]) {
|
||||
await page.setViewportSize({ width: 402, height })
|
||||
await expect.poll(() => page.getByRole('group', { name: '模型与推理等级', exact: true }).evaluate((panel) => {
|
||||
const viewport = window.visualViewport!
|
||||
const rect = panel.getBoundingClientRect()
|
||||
return Math.min(rect.left - viewport.offsetLeft, rect.top - viewport.offsetTop,
|
||||
viewport.offsetLeft + viewport.width - rect.right, viewport.offsetTop + viewport.height - rect.bottom)
|
||||
})).toBeGreaterThanOrEqual(11.5)
|
||||
expect(await page.evaluate(() => window.visualViewport!.scale)).toBeGreaterThan(2)
|
||||
const models = page.getByRole('menu', { name: '模型', exact: true })
|
||||
const names = models.locator('[data-model-id]').locator('..')
|
||||
expect(await names.evaluateAll(labels => labels.every(label =>
|
||||
getComputedStyle(label).textOverflow === 'ellipsis'))).toBe(true)
|
||||
expect(await names.evaluateAll(labels => labels.some(label =>
|
||||
label.scrollWidth > label.clientWidth))).toBe(true)
|
||||
expect(await models.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true)
|
||||
const widths = await models.locator('[role="menuitemradio"]').evaluateAll(rows => rows.map(row => ({
|
||||
right: row.lastElementChild!.getBoundingClientRect().right,
|
||||
width: row.getBoundingClientRect().width,
|
||||
})))
|
||||
expect(Math.max(...widths.map(row => row.right)) - Math.min(...widths.map(row => row.right))).toBeLessThan(1)
|
||||
expect(Math.max(...widths.map(row => row.width)) - Math.min(...widths.map(row => row.width))).toBeLessThan(1)
|
||||
await scrollToGroup(models, 1, 10)
|
||||
await expect.poll(() => models.locator('[data-menu-group-heading]').nth(1).getAttribute('data-stuck')).not.toBeNull()
|
||||
const coversViewport = await models.evaluate((node) => {
|
||||
const viewport = node.getBoundingClientRect()
|
||||
const heading = node.querySelector('[data-menu-group-heading][data-stuck]')!.getBoundingClientRect()
|
||||
return heading.left <= viewport.left + 1 && heading.right >= viewport.right - 1
|
||||
})
|
||||
expect(coversViewport).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 })
|
||||
await cdp.detach()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
}
|
||||
})
|
||||
|
||||
it.runIf(engine === chromium)('aligns the menu with its anchor after zooming and panning both viewport axes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-model-menu-pan'))
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
try {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
await trigger.click()
|
||||
const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true })
|
||||
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1.5 })
|
||||
await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(1.5)
|
||||
await frames(page)
|
||||
const target = await menu.evaluate((panel) => {
|
||||
const viewport = window.visualViewport!
|
||||
const anchor = document.querySelector(`[aria-controls="${panel.id}"]`)!.getBoundingClientRect()
|
||||
const rect = panel.getBoundingClientRect()
|
||||
// Keep the preferred placement away from clamps so adding an offset twice cannot pass.
|
||||
const left = Math.max(1, anchor.right - viewport.width + 12)
|
||||
const right = Math.min(window.innerWidth - viewport.width, anchor.right - rect.width - 12)
|
||||
const top = Math.max(1, anchor.top - 8 + 12 - viewport.height)
|
||||
const bottom = Math.min(window.innerHeight - viewport.height, anchor.top - 8 - rect.height - 12)
|
||||
const dx = (left + right) / 2 - viewport.offsetLeft
|
||||
const dy = (top + bottom) / 2 - viewport.offsetTop
|
||||
return { left, right, top, bottom, dx, dy,
|
||||
x: viewport.width / 2, y: viewport.height / 2 }
|
||||
})
|
||||
expect(target.right).toBeGreaterThan(target.left)
|
||||
expect(target.bottom).toBeGreaterThan(target.top)
|
||||
// Use native wheel scrolling; Linux headless synthetic touch can deliver events without scrolling.
|
||||
await cdp.send('Input.synthesizeScrollGesture', {
|
||||
x: target.x, y: target.y, xDistance: -target.dx, yDistance: 0, gestureSourceType: 'mouse', speed: 800,
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetLeft)).toBeGreaterThan(0)
|
||||
await frames(page)
|
||||
await cdp.send('Input.synthesizeScrollGesture', {
|
||||
x: target.x, y: target.y, yDistance: -target.dy, gestureSourceType: 'mouse', speed: 800,
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => Math.min(window.visualViewport!.offsetLeft,
|
||||
window.visualViewport!.offsetTop))).toBeGreaterThan(0)
|
||||
await expect.poll(() => menu.evaluate((panel) => {
|
||||
const anchor = document.querySelector(`[aria-controls="${panel.id}"]`)!.getBoundingClientRect()
|
||||
const rect = panel.getBoundingClientRect()
|
||||
return Math.max(Math.abs(rect.right - anchor.right), Math.abs(anchor.top - rect.bottom - 8))
|
||||
})).toBeLessThan(1)
|
||||
} finally {
|
||||
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 })
|
||||
await cdp.detach()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
}
|
||||
})
|
||||
|
||||
it.runIf(engine === chromium)('scrolls to and selects the last reasoning effort after zooming', async () => {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
try {
|
||||
await page.setViewportSize({ width: 402, height: 500 })
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /^推理等级/ }).click()
|
||||
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 })
|
||||
const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true })
|
||||
const last = menu.getByRole('menuitemradio').last()
|
||||
expect(await menu.getByRole('menuitemradio').count()).toBeGreaterThanOrEqual(7)
|
||||
await expect.poll(() => last.evaluate((row) => {
|
||||
const scroller = row.parentElement!
|
||||
scroller.scrollTop = scroller.scrollHeight
|
||||
return { overflow: getComputedStyle(scroller).overflowY, moved: scroller.scrollTop > 0 }
|
||||
})).toEqual({ overflow: 'auto', moved: true })
|
||||
await expect.poll(() => last.evaluate((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
const viewport = row.parentElement!.getBoundingClientRect()
|
||||
return rect.top >= viewport.top - 1 && rect.bottom <= viewport.bottom + 1
|
||||
})).toBe(true)
|
||||
// Dispatch the touch at the now-visible row; locator.click would auto-scroll even hidden overflow.
|
||||
const point = await last.evaluate((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
const viewport = window.visualViewport!
|
||||
return { x: rect.left + rect.width / 2 - viewport.offsetLeft,
|
||||
y: rect.top + rect.height / 2 - viewport.offsetTop }
|
||||
})
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] })
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ reasoningEffort: 'max' })
|
||||
} finally {
|
||||
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 })
|
||||
await cdp.detach()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -319,6 +319,44 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await details.getByRole('button', { name: 'Close details' }).click()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps trajectory folding and search usable at 390px in every locale', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-toolbar-narrow'))
|
||||
await ensureSeedOpen(page)
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
const toolbar = page.getByRole('toolbar')
|
||||
const search = toolbar.getByRole('searchbox')
|
||||
try {
|
||||
for (const locale of ['en', 'zh', 'ja', 'es', 'fr', 'de', 'ru', 'pt']) {
|
||||
await scaffold.ctx.settings.update('locale', { preference: locale })
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.lang))
|
||||
.toBe(locale === 'zh' ? 'zh-CN' : locale)
|
||||
await search.fill('NAVIGATION_OK')
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect.poll(() => toolbar.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return {
|
||||
fits: element.scrollWidth <= element.clientWidth + 1,
|
||||
controlsFit: [...element.querySelectorAll('button:not([hidden]), input')].every((control) => {
|
||||
const rect = control.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0
|
||||
&& rect.left >= bounds.left && rect.right <= bounds.right
|
||||
&& rect.top >= bounds.top && rect.bottom <= bounds.bottom
|
||||
}),
|
||||
}
|
||||
}), { message: `${locale} toolbar at 390px` }).toEqual({ fits: true, controlsFit: true })
|
||||
expect(await search.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(await search.inputValue()).toBe('NAVIGATION_OK')
|
||||
await search.fill('')
|
||||
const subcalls = toolbar.getByRole('button').last()
|
||||
await subcalls.click()
|
||||
expect(await subcalls.isVisible()).toBe(true)
|
||||
await page.setViewportSize({ width: 1680, height: 1_000 })
|
||||
}
|
||||
} finally {
|
||||
await scaffold.ctx.settings.update('locale', { preference: 'en' })
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('restores Assistant timing from recorded history', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timing'))
|
||||
await ensureSeedOpen(page)
|
||||
|
||||
@@ -305,6 +305,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await picker.getByText('暂无可用模型', { exact: true }).waitFor()
|
||||
expect(await picker.getByRole('menuitemradio').count()).toBe(0)
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual(selection)
|
||||
expect(await picker.getByRole('menu', { name: '模型', exact: true }).count()).toBe(0)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'empty.expected.md'), await picker.ariaSnapshot(), MODE)
|
||||
await seat.press('Escape')
|
||||
await picker.waitFor({ state: 'hidden' })
|
||||
|
||||
@@ -1249,6 +1249,19 @@ describe('web e2e: plugin manager', () => {
|
||||
const registryToggle = dialog.getByRole('button', { name: /^安装源/ })
|
||||
await registryToggle.click()
|
||||
const registry = probe.locator('[data-install-registry]')
|
||||
await registry.waitFor()
|
||||
for (const width of [390, 800, 1680]) {
|
||||
await probe.setViewportSize({ width, height: 1000 })
|
||||
await probe.clock.runFor(300)
|
||||
await expect.poll(async () => {
|
||||
const rect = await registry.boundingBox()
|
||||
const anchor = await registryToggle.boundingBox()
|
||||
return rect !== null && anchor !== null
|
||||
&& rect.x >= 11 && rect.x + rect.width <= width - 11
|
||||
&& rect.y >= 11 && rect.y + rect.height <= 989
|
||||
&& Math.abs(rect.x - Math.max(12, Math.min(anchor.x + anchor.width - rect.width, width - rect.width - 12))) <= 1
|
||||
}).toBe(true)
|
||||
}
|
||||
const offered = registry.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true })
|
||||
const custom = registry.getByRole('radio', { name: '自定义地址', exact: true })
|
||||
field = registry.getByRole('textbox', { name: '自定义地址', exact: true })
|
||||
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeComposerDraft } from './support.ts'
|
||||
|
||||
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
|
||||
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
|
||||
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail', '\n\nExpanded update']
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url))
|
||||
/** Computed mask of the animated whale: the ui-chat package asset URL, whose `@` is percent-encoded. */
|
||||
const WHALE_MASK = /^url\("([^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-chat\/assets\/running-whale%402x-[0-9a-f]{12}\.png)"\)$/
|
||||
|
||||
class PausedReasoningAdapter extends LlmAdapter {
|
||||
constructor(private readonly prefix: string) { super() }
|
||||
override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] }
|
||||
readonly stages = DELTAS.map(text => ({
|
||||
text,
|
||||
@@ -29,13 +30,14 @@ class PausedReasoningAdapter extends LlmAdapter {
|
||||
|
||||
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
yield { type: 'block-start', index: 0, blockType: 'reasoning' }
|
||||
if (this.prefix !== '') yield { type: 'reasoning-delta', index: 0, text: this.prefix }
|
||||
for (const stage of this.stages) {
|
||||
yield { type: 'reasoning-delta', index: 0, text: stage.text }
|
||||
stage.arrived.resolve(undefined)
|
||||
await stage.proceed.promise
|
||||
options.signal?.throwIfAborted()
|
||||
}
|
||||
yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: DELTAS.join('') } }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: this.prefix + DELTAS.join('') } }
|
||||
yield { type: 'block-start', index: 1, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 1, text: 'Done' }
|
||||
yield { type: 'block-end', index: 1, block: { type: 'text', text: 'Done' } }
|
||||
@@ -43,9 +45,9 @@ class PausedReasoningAdapter extends LlmAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
it('shows completed paragraph first lines across blank lines with a right-edge fade', async () => {
|
||||
it.each(['plain', 'signature'])('shows completed paragraph first lines with a right-edge fade (%s)', async (format) => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
const adapter = new PausedReasoningAdapter()
|
||||
const adapter = new PausedReasoningAdapter(format === 'signature' ? '<signature>metadata.payload</signature>\n\n' : '')
|
||||
try {
|
||||
scaffold.ctx.effect(() => scaffold.ctx.llm.registerAdapter(['reasoning-preview-test'], adapter))
|
||||
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'reasoning-preview-test', model: 'paused' })
|
||||
@@ -61,8 +63,10 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
await writeComposerDraft(page, input, 'Show a streamed reasoning preview.')
|
||||
await input.press('Enter')
|
||||
|
||||
const [first, second, third] = adapter.stages
|
||||
if (first === undefined || second === undefined || third === undefined) throw new Error('preview stages are incomplete')
|
||||
const [first, second, third, fourth] = adapter.stages
|
||||
if (first === undefined || second === undefined || third === undefined || fourth === undefined) {
|
||||
throw new Error('preview stages are incomplete')
|
||||
}
|
||||
await first.arrived.promise
|
||||
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
|
||||
await expandOwningTurnProcess(page, reasoning)
|
||||
@@ -120,6 +124,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
await page.emulateMedia({ reducedMotion: null, forcedColors: null })
|
||||
}
|
||||
expect(await reasoning.getAttribute('data-preview')).toBeNull()
|
||||
expect(await reasoning.textContent()).not.toContain('metadata.payload')
|
||||
|
||||
first.proceed.resolve(undefined)
|
||||
await second.arrived.promise
|
||||
@@ -138,7 +143,14 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
await compareOrRefreshGolden(UI_EXPECTED,
|
||||
await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
|
||||
await reasoning.getByRole('button').click()
|
||||
await reasoning.getByText('More detail', { exact: false }).waitFor()
|
||||
expect(await reasoning.textContent()).not.toContain('metadata.payload')
|
||||
third.proceed.resolve(undefined)
|
||||
await fourth.arrived.promise
|
||||
await reasoning.getByText('Expanded update', { exact: true }).waitFor()
|
||||
expect(await reasoning.textContent()).not.toContain('metadata.payload')
|
||||
fourth.proceed.resolve(undefined)
|
||||
await settled
|
||||
await page.getByText('Done', { exact: true }).waitFor()
|
||||
await whale.waitFor({ state: 'detached' })
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/** Zoomed automation pickers truncate wide labels and keep vertical choices reachable. */
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type {} from '@deepseek-ai/dsh-schedule'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { revealNavigationDrawer, saveFailureShot } from './support.ts'
|
||||
|
||||
const TITLE = 'Inspect zoomed automation pickers'
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
// Two digits must overflow at 500% zoom; narrower system UI fonts can fit.
|
||||
await scaffold.ctx.settings.update('ui-theme', { textFontFamily: 'monospace' })
|
||||
await scaffold.ctx.schedule.create({
|
||||
title: TITLE, prompt: 'Inspect the deployment log',
|
||||
timing: { kind: 'rrule', rrule: 'DTSTART;TZID=Asia/Shanghai:20990825T200800\nRRULE:FREQ=DAILY;COUNT=1' },
|
||||
target: { kind: 'new_once' },
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
await scaffold?.close()
|
||||
}
|
||||
})
|
||||
|
||||
/** Assert that the complete floating card stays inside the zoomed visual viewport. */
|
||||
async function expectContained(panel: Locator): Promise<void> {
|
||||
await expect.poll(() => panel.evaluate((node) => {
|
||||
const view = window.visualViewport!
|
||||
const rect = node.getBoundingClientRect()
|
||||
return Math.min(rect.left - view.offsetLeft, rect.top - view.offsetTop,
|
||||
view.offsetLeft + view.width - rect.right, view.offsetTop + view.height - rect.bottom)
|
||||
})).toBeGreaterThanOrEqual(11.5)
|
||||
}
|
||||
|
||||
/** Narrow labels ellipsize instead of increasing their row's width. */
|
||||
async function expectEllipsizedText(labels: Locator): Promise<void> {
|
||||
expect(await labels.evaluateAll(nodes => nodes.every((node) => {
|
||||
const style = getComputedStyle(node)
|
||||
return style.textOverflow === 'ellipsis' && style.overflowX === 'hidden'
|
||||
}))).toBe(true)
|
||||
expect(await labels.evaluateAll(nodes => nodes.some(node => node.scrollWidth > node.clientWidth))).toBe(true)
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{ locale: 'en-US', tasks: 'Automation tasks', edit: 'Edit', time: 'Time', second: 'Second', zone: 'Time zone' },
|
||||
{ locale: 'zh-CN', tasks: '自动化任务', edit: '编辑', time: '时间', second: '秒', zone: '时区' },
|
||||
])('automation pickers ($locale)', (copy) => {
|
||||
it.each(['clock', 'zone'] as const)('ellipsizes the %s labels and keeps vertical choices reachable at 500% zoom', async (kind) => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 }, locale: copy.locale, timezoneId: 'Asia/Shanghai',
|
||||
isMobile: true, hasTouch: true,
|
||||
})
|
||||
const page: Page = await context.newPage()
|
||||
try {
|
||||
const consoleWatch = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await revealNavigationDrawer(page, copy.locale === 'zh-CN' ? 'zh' : 'en')
|
||||
await page.getByRole('button', { name: copy.tasks, exact: true }).click()
|
||||
const detail = page.getByTestId('task-manager-page')
|
||||
await detail.getByRole('button', { name: TITLE, exact: true }).click()
|
||||
await detail.getByRole('button', { name: copy.edit, exact: true }).click()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
const trigger = kind === 'clock'
|
||||
? detail.getByRole('button', { name: copy.time, exact: true })
|
||||
: detail.getByRole('button', { name: new RegExp(`^${copy.zone}`) })
|
||||
await trigger.click()
|
||||
const panel = kind === 'clock'
|
||||
? page.getByRole('dialog', { name: copy.time, exact: true })
|
||||
: page.getByRole('menu')
|
||||
const cdp = await context.newCDPSession(page)
|
||||
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 5 })
|
||||
await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(5)
|
||||
await expectContained(panel)
|
||||
|
||||
expect(await panel.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true)
|
||||
if (kind === 'clock') {
|
||||
await expectEllipsizedText(panel.locator('[role="option"] > span'))
|
||||
const columns = panel.getByRole('listbox')
|
||||
expect(await columns.evaluateAll(nodes => nodes.every(node =>
|
||||
node.scrollWidth <= node.clientWidth))).toBe(true)
|
||||
const seconds = panel.getByRole('listbox', { name: copy.second, exact: true })
|
||||
expect(await seconds.evaluate((node) => {
|
||||
node.scrollTop = node.scrollHeight
|
||||
return node.scrollTop > 0
|
||||
})).toBe(true)
|
||||
const last = seconds.getByRole('option', { name: '59', exact: true })
|
||||
await expect.poll(() => last.evaluate((node) => {
|
||||
const label = node.firstElementChild!.getBoundingClientRect()
|
||||
const column = node.parentElement!.getBoundingClientRect()
|
||||
return label.left >= column.left && label.right <= column.right
|
||||
&& label.top >= column.top && label.bottom <= column.bottom
|
||||
})).toBe(true)
|
||||
await last.click()
|
||||
await expect.poll(() => trigger.textContent()).toMatch(/:59$/)
|
||||
} else {
|
||||
await expectEllipsizedText(panel.locator('[role="menuitem"] > span'))
|
||||
const viewport = panel.locator('[role="presentation"]').last()
|
||||
expect(await viewport.evaluate((node) => {
|
||||
node.scrollTop = node.scrollHeight
|
||||
return node.scrollTop > 0 && node.scrollWidth <= node.clientWidth
|
||||
})).toBe(true)
|
||||
const last = panel.getByRole('menuitem').last()
|
||||
await expect.poll(() => last.evaluate((node) => {
|
||||
const row = node.getBoundingClientRect()
|
||||
const viewport = node.parentElement!.getBoundingClientRect()
|
||||
return row.left >= viewport.left && row.right <= viewport.right
|
||||
&& row.top >= viewport.top && row.bottom <= viewport.bottom
|
||||
})).toBe(true)
|
||||
await last.click()
|
||||
await panel.waitFor({ state: 'hidden' })
|
||||
}
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
expect(consoleWatch.warnings).toEqual([])
|
||||
await cdp.detach()
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, `schedule-picker-${copy.locale}-${kind}`)
|
||||
throw error
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Browser } from 'playwright'
|
||||
import { chromium, webkit, type Browser } from 'playwright'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold,
|
||||
@@ -16,7 +16,9 @@ const fixture = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/s
|
||||
const bundle = fileURLToPath(new URL('../../../packages/experimental/inspector-profile', import.meta.url))
|
||||
const picker = fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url))
|
||||
|
||||
it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and Chat groups through the optional bundle', async () => {
|
||||
it.skipIf(webSnapshotMode() === 'record').each([
|
||||
{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit },
|
||||
])('inspects a replayed Session log and Chat groups through the optional bundle ($name)', async ({ engine }) => {
|
||||
const resources: { browser?: Browser; scaffold?: WebScaffold } = {}
|
||||
onTestFinished(async () => {
|
||||
try { await resources.browser?.close() } finally { await resources.scaffold?.close() }
|
||||
@@ -29,7 +31,7 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C
|
||||
extraOverlayPath: picker, replayFixture, compareReplaySession: 'read-only', paceMs: 5,
|
||||
})
|
||||
resources.scaffold = scaffold
|
||||
const browser = await chromium.launch()
|
||||
const browser = await engine.launch()
|
||||
resources.browser = browser
|
||||
const page = await newEnglishPage(browser)
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
@@ -48,6 +50,18 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C
|
||||
expect(await mode.inputValue()).toBe('session-log')
|
||||
const raw = page.getByRole('table', { name: 'Raw log', exact: true })
|
||||
await raw.getByRole('button', { name: 'Filter types', exact: true }).click()
|
||||
const filter = page.getByRole('dialog', { name: 'Filter types', exact: true })
|
||||
const keyword = page.getByRole('combobox', { name: 'Type keyword', exact: true })
|
||||
await filter.waitFor()
|
||||
await expect.poll(() => keyword.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
for (const width of [1680, 800, 390, 1680]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await expect.poll(async () => {
|
||||
const rect = await filter.boundingBox()
|
||||
return rect !== null && rect.x >= 11 && rect.x + rect.width <= width - 11
|
||||
&& rect.y >= 11 && rect.y + rect.height <= 989
|
||||
}).toBe(true)
|
||||
}
|
||||
await page.getByRole('combobox', { name: 'Type keyword', exact: true }).fill('user/message')
|
||||
await page.getByRole('option', { name: 'user/message', exact: true }).click()
|
||||
await raw.getByRole('button', { name: 'user/message', exact: true }).first().click()
|
||||
|
||||
@@ -19,7 +19,9 @@ afterAll(async () => {
|
||||
})
|
||||
|
||||
describe('navigation without a selected Session', () => {
|
||||
it.each(['web', 'win32', 'linux', 'darwin'] as const)('%s preserves its empty-header geometry and sidebar access', async (platform) => {
|
||||
// The resident header keeps the blank-Session row height on every platform,
|
||||
// so the Hero centers below the same header before and after a Workspace is bound.
|
||||
it.each(['web', 'win32', 'linux', 'darwin'] as const)('%s keeps the blank-Session header height and sidebar access', async (platform) => {
|
||||
const page = await newEnglishPage(browser)
|
||||
try {
|
||||
if (platform !== 'web') {
|
||||
@@ -41,14 +43,14 @@ describe('navigation without a selected Session', () => {
|
||||
const sessionHeader = page.locator('[data-slot="conversation.session.header"]')
|
||||
expect(await sessionHeader.count()).toBe(0)
|
||||
const header = page.locator('[data-slot="conversation.header"] > header')
|
||||
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0)
|
||||
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(40)
|
||||
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
||||
const reopen = platform === 'darwin'
|
||||
? page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true })
|
||||
: page.getByRole('button', { name: 'Open sidebar', exact: true })
|
||||
await reopen.waitFor({ state: 'visible' })
|
||||
expect(await sessionHeader.count()).toBe(0)
|
||||
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0)
|
||||
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(40)
|
||||
if (platform === 'darwin') {
|
||||
expect(await page.locator('[data-shell-leading]').getByRole('button', { name: 'New session', exact: true }).isVisible()).toBe(true)
|
||||
}
|
||||
|
||||
@@ -473,6 +473,7 @@ function assertLeanChildRecord(agent: Agent): void {
|
||||
* Agent Note owns the rationale and its sources.
|
||||
*/
|
||||
const EXPECTED_TOOLS = [
|
||||
'apply_patch',
|
||||
'ask_user_question',
|
||||
'bash',
|
||||
'create_goal',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// draws the panel is a recorded, reversible choice, so this file asserts against
|
||||
// the frame's track as much as against the panel itself. The way back in while
|
||||
// collapsed is not in the column at all: it is one button in the conversation
|
||||
// header, and it leaves when the panel opens.
|
||||
// header, visible only while the panel is collapsed.
|
||||
//
|
||||
// Ordering is the product's own: the hero comes before any session, so the
|
||||
// empty right edge is asserted first and the session-bound cases follow in a
|
||||
@@ -114,7 +114,7 @@ async function holdPanelSlide(panel: Locator) {
|
||||
})
|
||||
}
|
||||
|
||||
/** The expand button in the conversation header, present only while collapsed. */
|
||||
/** The conversation header's expand control, visible only while collapsed. */
|
||||
function expandOf(page: Page): Locator {
|
||||
return page.locator('[data-sidebar-right-expand]')
|
||||
}
|
||||
@@ -379,8 +379,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-pageup'))
|
||||
const frame = page.locator('[class*="frame"]').first()
|
||||
const composer = page.locator('textarea, [contenteditable="true"]').first()
|
||||
// The expand button exists only while the panel is collapsed, when its
|
||||
// docked hosts sit translated past the frame's right edge.
|
||||
// Collapsed docked hosts sit translated past the frame's right edge.
|
||||
await expandOf(page).waitFor({ timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.pressSequentially('caret')
|
||||
@@ -644,6 +643,74 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it.each([390, 767])('keeps the mobile conversation title still while the right sidebar slides at %ipx', async (viewportWidth) => {
|
||||
const caseName = `mobile-title-${viewportWidth}`
|
||||
const mobilePage = await browser.newPage({
|
||||
viewport: { width: viewportWidth, height: 844 },
|
||||
locale: 'en-US', timezoneId: 'Asia/Shanghai',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true, hasTouch: true, reducedMotion: 'no-preference',
|
||||
storageState: await page.context().storageState(),
|
||||
})
|
||||
const mobileTripwire = watchConsole(mobilePage)
|
||||
try {
|
||||
await mobilePage.goto(page.url(), { waitUntil: 'load' })
|
||||
const column = await resetSidebar(mobilePage)
|
||||
const panel = column.locator('[data-sidebar-right-panel]')
|
||||
const title = mobilePage.locator('[class*="titleCluster"]').first()
|
||||
const utilities = mobilePage.locator('[class*="headerUtilities"]').first()
|
||||
await expect.poll(() => panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
|
||||
await column.locator('[data-sidebar-right-toggle]').tap()
|
||||
await panel.evaluate(async (node) => {
|
||||
await Promise.allSettled(node.getAnimations({ subtree: true }).map(animation => animation.finished))
|
||||
})
|
||||
await expandOf(mobilePage).waitFor()
|
||||
const titleNode = await title.locator('[class*="crumbCurrent"]').elementHandle()
|
||||
if (titleNode === null) throw new Error('conversation title is not rendered')
|
||||
const titleBefore = await title.boundingBox()
|
||||
const utilitiesBefore = await utilities.boundingBox()
|
||||
expect(titleBefore?.width).toBeGreaterThan(0)
|
||||
expect(utilitiesBefore?.width).toBeGreaterThan(0)
|
||||
await shot(mobilePage, `${caseName}-collapsed`)
|
||||
try {
|
||||
for (const opening of [true, false]) {
|
||||
const held = await holdPanelSlide(panel)
|
||||
try {
|
||||
await (opening ? expandOf(mobilePage) : column.locator('[data-sidebar-right-toggle]')).tap()
|
||||
await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused')
|
||||
const progress = await held.evaluate(state => state.animation?.effect?.getComputedTiming().progress)
|
||||
expect(progress).toBeGreaterThan(0)
|
||||
expect(progress).toBeLessThan(1)
|
||||
expect(await title.boundingBox()).toEqual(titleBefore)
|
||||
expect(await utilities.boundingBox()).toEqual(utilitiesBefore)
|
||||
expect(await titleNode.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await expandOf(mobilePage).isDisabled()).toBe(opening)
|
||||
expect(await mobilePage.getByRole('button', { name: 'Open right sidebar', exact: true }).count()).toBe(opening ? 0 : 1)
|
||||
await shot(mobilePage, `${caseName}-${opening ? 'opening' : 'closing'}`)
|
||||
await held.evaluate((state) => { (state.animation as Animation).finish() })
|
||||
expect(await title.boundingBox()).toEqual(titleBefore)
|
||||
expect(await utilities.boundingBox()).toEqual(utilitiesBefore)
|
||||
} finally {
|
||||
await held.evaluate((state) => {
|
||||
state.dispose()
|
||||
if (state.animation?.playState === 'paused') state.animation.finish()
|
||||
})
|
||||
await held.dispose()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await titleNode.dispose()
|
||||
}
|
||||
expect(mobileTripwire.pageErrors).toEqual([])
|
||||
expect(mobileTripwire.warnings).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(mobilePage, `web-e2e-sidebar-right-${caseName}`)
|
||||
throw error
|
||||
} finally {
|
||||
await mobilePage.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a capacity-closed panel closed after widening and uses fullscreen on a narrow viewport', async () => {
|
||||
const viewport = page.viewportSize()
|
||||
if (viewport === null) throw new Error('expected a fixed viewport')
|
||||
@@ -668,6 +735,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
|
||||
await expect.poll(async () => await width(panel)).toBe(767)
|
||||
await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
|
||||
expect(await expandOf(page).count()).toBe(0)
|
||||
expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0)
|
||||
await column.locator('[data-sidebar-right-mode="push"]').click()
|
||||
await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- strong: Agent task
|
||||
- text: In progress
|
||||
- paragraph: Created by the root agent
|
||||
- text: "task-1Owner: LeadWrite scopes: src/web"
|
||||
- text: "task-1Owner: session-{{uuid}}Write scopes: src/web"
|
||||
- article:
|
||||
- strong: Responsive task
|
||||
- text: Pending
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
- strong: Agent task
|
||||
- text: In progress
|
||||
- paragraph: Created by the root agent
|
||||
- text: "task-1Owner: LeadWrite scopes: src/web"
|
||||
- text: "task-1Owner: session-{{uuid}}Write scopes: src/web"
|
||||
|
||||
@@ -61,10 +61,36 @@ describe('web e2e: startup auto-selection', () => {
|
||||
if (Object.values(refs).some(node => node === null)) throw new Error('incomplete initial Hero tree')
|
||||
;(window as unknown as { __heroTree: typeof refs }).__heroTree = refs
|
||||
})
|
||||
// The input card's distance from the conversation top, once it holds for three frames.
|
||||
const heroCardOffset = (): Promise<number> => page.evaluate(async () => {
|
||||
const read = (): number => {
|
||||
const root = document.querySelector('div[data-phase="hero"]')
|
||||
const card = document.querySelector('[data-composer-card]')
|
||||
if (root === null || card === null) throw new Error('incomplete Hero composer')
|
||||
return card.getBoundingClientRect().top - root.getBoundingClientRect().top
|
||||
}
|
||||
const deadline = performance.now() + 5_000
|
||||
let previous = read()
|
||||
let stableFrames = 0
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
|
||||
const current = read()
|
||||
stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
|
||||
if (stableFrames >= 3) return current
|
||||
previous = current
|
||||
}
|
||||
throw new Error('Hero input card did not settle')
|
||||
})
|
||||
const sessionlessOffset = await heroCardOffset()
|
||||
|
||||
// A registered Workspace is the precondition for the reload case below;
|
||||
// this first connection is also the no-Workspace → Workspace path.
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
|
||||
await page.locator('[data-slot="conversation.session.header"]').waitFor({ timeout: 15_000 })
|
||||
// Binding the Workspace keeps the input card at the same height in the Hero.
|
||||
// This holds for the default plugin set: `conversation.hero.options` entries
|
||||
// render only with a Session and can change the Hero's height.
|
||||
expect(Math.abs(await heroCardOffset() - sessionlessOffset)).toBeLessThanOrEqual(0.5)
|
||||
|
||||
expect(await page.evaluate(() => {
|
||||
const before = (window as unknown as { __heroTree: Record<string, Element> }).__heroTree
|
||||
|
||||
@@ -371,8 +371,8 @@ describe.each(['reconnect', 'delayed-inbox'] as const)('web e2e: composer shortc
|
||||
server.onMessage((message) => {
|
||||
const frame = parseRemoteStreamServerMessage(String(message))
|
||||
if (holdInbox && frame.type === 'item') {
|
||||
const value = frame.value as { type?: string; key?: string }
|
||||
if (value.type === 'projection' && value.key === 'inbox') heldStream = frame.streamId
|
||||
const value = frame.value as { type?: string; changes?: readonly { key: string }[] }
|
||||
if (value.type === 'projection' && value.changes?.some(change => change.key === 'inbox') === true) heldStream = frame.streamId
|
||||
}
|
||||
// Hold the remaining control stream in FIFO order, while history keeps flowing.
|
||||
if (holdInbox && frame.streamId === heldStream) heldControl.push(() => { route.send(message) })
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"tests/cua-permissions.e2e.ts",
|
||||
"tests/schedule-header.e2e.ts",
|
||||
"tests/schedule-phone.e2e.ts",
|
||||
"tests/schedule-pickers.e2e.ts",
|
||||
"tests/worktree-opt-in.e2e.ts",
|
||||
"tests/session-inspector.e2e.ts",
|
||||
"tests/shared-inspector.e2e.ts",
|
||||
|
||||
+19
-4
@@ -1551,7 +1551,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-fs-local`
|
||||
|
||||
- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts)
|
||||
- `source`: [`packages/fs/fs-local/src/index.ts:51`](../packages/fs/fs-local/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the local filesystem backend. */
|
||||
@@ -1947,7 +1947,7 @@ export interface Config extends ProtocolConfig, ModelListingConfig {
|
||||
|
||||
- `inject`: `llm`
|
||||
- `refs`: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) · `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/llm/llm-pi-ai/src/config.ts:222`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
- `source`: [`packages/llm/llm-pi-ai/src/config.ts:251`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin configuration: the provider routes this instance owns. */
|
||||
@@ -2043,6 +2043,18 @@ export interface PiAiProviderProfile {
|
||||
* requests instead of being rejected by a request-size cap.
|
||||
*/
|
||||
maxRequestImageBytes?: number
|
||||
/**
|
||||
* Maximum retained image occurrences per request. A request carrying more
|
||||
* fails with `IMAGE_OFFLOAD_REQUIRED` naming how many of the oldest
|
||||
* occurrences to offload.
|
||||
*/
|
||||
maxRequestImages?: number
|
||||
/**
|
||||
* Oldest occurrences offloaded per count overflow (default 10, capped at
|
||||
* {@link maxRequestImages}); an explicit value must not exceed it. Larger
|
||||
* steps rewrite the request prefix less often.
|
||||
*/
|
||||
imageOffloadCountQuantum?: number
|
||||
/** Total-pixel budget for each deterministic inline request version. */
|
||||
requestImagePixelBudget?: number
|
||||
/**
|
||||
@@ -3760,7 +3772,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-system-prompt`
|
||||
|
||||
- `source`: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts)
|
||||
- `source`: [`packages/core/system-prompt/src/index.ts:265`](../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */
|
||||
@@ -3994,7 +4006,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
- `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory`
|
||||
- `source`: [`packages/fs/tool-fs/src/index.ts:26`](../packages/fs/tool-fs/src/index.ts)
|
||||
- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
@@ -4007,6 +4019,8 @@ export interface Config {
|
||||
write?: boolean
|
||||
/** Register `edit` and its guidance. Defaults to true. */
|
||||
edit?: boolean
|
||||
/** Register `apply_patch` and its guidance beside `edit`. Defaults to false; shipped compositions set it on their `tool-fs` rows. */
|
||||
applyPatch?: boolean
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
readLimit?: number
|
||||
/** Maximum characters returned for a single line before truncation. */
|
||||
@@ -5008,6 +5022,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
| `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-webworker-runtime` | — | [`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-fs-patch` | — | [`packages/fs/fs-patch/src/index.ts`](../packages/fs/fs-patch/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-home-paths` | — | [`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-hook-protocol` | — | [`packages/experimental/hook-protocol/src/index.ts`](../packages/experimental/hook-protocol/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-http-proxy` | — | [`packages/util/http-proxy/src/index.ts`](../packages/util/http-proxy/src/index.ts) |
|
||||
|
||||
@@ -1553,7 +1553,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-fs-local`
|
||||
|
||||
- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts)
|
||||
- `source`: [`packages/fs/fs-local/src/index.ts:51`](../packages/fs/fs-local/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the local filesystem backend. */
|
||||
@@ -1949,7 +1949,7 @@ export interface Config extends ProtocolConfig, ModelListingConfig {
|
||||
|
||||
- `inject`: `llm`
|
||||
- `refs`: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) · `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/llm/llm-pi-ai/src/config.ts:222`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
- `source`: [`packages/llm/llm-pi-ai/src/config.ts:251`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin configuration: the provider routes this instance owns. */
|
||||
@@ -2045,6 +2045,18 @@ export interface PiAiProviderProfile {
|
||||
* requests instead of being rejected by a request-size cap.
|
||||
*/
|
||||
maxRequestImageBytes?: number
|
||||
/**
|
||||
* Maximum retained image occurrences per request. A request carrying more
|
||||
* fails with `IMAGE_OFFLOAD_REQUIRED` naming how many of the oldest
|
||||
* occurrences to offload.
|
||||
*/
|
||||
maxRequestImages?: number
|
||||
/**
|
||||
* Oldest occurrences offloaded per count overflow (default 10, capped at
|
||||
* {@link maxRequestImages}); an explicit value must not exceed it. Larger
|
||||
* steps rewrite the request prefix less often.
|
||||
*/
|
||||
imageOffloadCountQuantum?: number
|
||||
/** Total-pixel budget for each deterministic inline request version. */
|
||||
requestImagePixelBudget?: number
|
||||
/**
|
||||
@@ -3762,7 +3774,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-system-prompt`
|
||||
|
||||
- `source`: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts)
|
||||
- `source`: [`packages/core/system-prompt/src/index.ts:265`](../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */
|
||||
@@ -3996,7 +4008,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
- `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory`
|
||||
- `source`: [`packages/fs/tool-fs/src/index.ts:26`](../packages/fs/tool-fs/src/index.ts)
|
||||
- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
@@ -4009,6 +4021,8 @@ export interface Config {
|
||||
write?: boolean
|
||||
/** Register `edit` and its guidance. Defaults to true. */
|
||||
edit?: boolean
|
||||
/** Register `apply_patch` and its guidance beside `edit`. Defaults to false; shipped compositions set it on their `tool-fs` rows. */
|
||||
applyPatch?: boolean
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
readLimit?: number
|
||||
/** Maximum characters returned for a single line before truncation. */
|
||||
@@ -5010,6 +5024,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-webworker-runtime` | — | [`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-fs-patch` | — | [`packages/fs/fs-patch/src/index.ts`](../packages/fs/fs-patch/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-home-paths` | — | [`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-hook-protocol` | — | [`packages/experimental/hook-protocol/src/index.ts`](../packages/experimental/hook-protocol/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-http-proxy` | — | [`packages/util/http-proxy/src/index.ts`](../packages/util/http-proxy/src/index.ts) |
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: dba2c72ee6ffa930
|
||||
zh: b65c27fbb9129ec8
|
||||
/development-guide/contributor-reference/git-integrations:
|
||||
en: 2efb7c4e482413fc
|
||||
zh: 75386476a283111a
|
||||
en: 075658ecfc10da20
|
||||
zh: 69e664417bbde87e
|
||||
/development-guide/contributor-reference/ci-gates:
|
||||
en: dfa3a24d9070478f
|
||||
zh: fa889b3536ab58e2
|
||||
|
||||
@@ -155,6 +155,8 @@ Contributors can opt into the comprehensive local gate set with `pnpm run check:
|
||||
|
||||
Outside CI (`CI` other than `true`), default worker counts follow the [local worker budget](../scripts/parallelism.ts): 12 for gate schedulers, `build`, `typecheck`, Oxlint, and publint, and 48 for Vitest forks and local coverage. `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 8 gates at once, in CI as well. `os.availableParallelism()` reports host CPUs rather than a container's CPU quota, so uncapped defaults oversubscribe shared hosts. CI keeps the runner's CPU count. `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` override the defaults.
|
||||
|
||||
`run-gates` gives the CLI help generator one child at a time; direct `pnpm run verify-cli-help` uses the standalone worker budget. `DSH_CLI_HELP_CONCURRENCY` overrides that child limit with a positive integer validated before startup. Each invocation retains its private `DSH_HOME` and `DSH_AGENTS_HOME` and 30-second deadline.
|
||||
|
||||
### CI gates
|
||||
|
||||
The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. Coverage partitions reuse file timings scoped to their platform and runner pool to balance work within the configured worker limit. Native Windows lanes run in the separate [CI Windows workflow](../.github/workflows/ci-windows.yml) and Python checks in the separate [CI Python workflow](../.github/workflows/ci-python.yml), each of which can be re-run independently. Required benchmarks run separately on standard GitHub-hosted Linux; the [benchmark runner reference](../benchmarks/AGENTS.md) owns routing and the job timeout. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.
|
||||
|
||||
@@ -160,6 +160,8 @@ pi-ai 检查会在内存中重新生成预期元数据。数据过期时,检
|
||||
|
||||
在 CI 之外(`CI` 不为 `true`),默认 worker 数遵循[本地 worker 预算](../scripts/parallelism.ts):门禁调度器、`build`、`typecheck`、Oxlint 与 publint 为 12,Vitest fork 与本地覆盖率为 48。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多同时运行 8 个门禁,CI 中同样如此。`os.availableParallelism()` 报告的是宿主 CPU 数而不是容器的 CPU 配额,因此不设上限的默认值会在共享主机上超额订阅。CI 仍使用 runner 的 CPU 数。`DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest 的 `--maxWorkers` 可覆盖这些默认值。
|
||||
|
||||
`run-gates` 让 CLI 帮助生成器每次只运行一个子进程;直接执行 `pnpm run verify-cli-help` 时使用独立运行的 worker 预算。`DSH_CLI_HELP_CONCURRENCY` 可用正整数覆盖该子进程上限,并在启动前校验。每次调用仍使用独立的 `DSH_HOME` 与 `DSH_AGENTS_HOME`,超时限制仍为 30 秒。
|
||||
|
||||
### CI 门禁
|
||||
|
||||
keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。覆盖率分区复用按平台和运行器池隔离的文件耗时,在配置的 worker 上限内均衡工作量。原生 Windows lane 运行在独立的 [CI Windows 工作流](../.github/workflows/ci-windows.yml)中,Python 检查运行在独立的 [CI Python 工作流](../.github/workflows/ci-python.yml)中,两者都可单独重新运行。必需 benchmark 在标准 GitHub 托管 Linux 上独立运行;[benchmark 运行器说明](../benchmarks/AGENTS.md)拥有路由及 job 超时。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。
|
||||
|
||||
@@ -23,11 +23,11 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:691`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:671`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:698`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:677`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:684`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:680`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:707`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
@@ -48,9 +48,10 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
|
||||
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` |
|
||||
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
|
||||
| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes`, `server` |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:691`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:671`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:698`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:677`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:684`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:680`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:707`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
@@ -50,9 +50,10 @@
|
||||
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` |
|
||||
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
|
||||
| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes`, `server` |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
|
||||
@@ -63,6 +63,7 @@ flowchart TD
|
||||
pkg_fs["fs"]
|
||||
pkg_fs_local["fs-local"]
|
||||
pkg_fs_observation_policy["fs-observation-policy"]
|
||||
pkg_fs_patch["fs-patch"]
|
||||
pkg_fs_sandbox["fs-sandbox"]
|
||||
pkg_tool_fs["tool-fs"]
|
||||
pkg_tool_fs_search["tool-fs-search"]
|
||||
@@ -1531,6 +1532,7 @@ flowchart TD
|
||||
| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | — |
|
||||
| [`scope`](../packages/core/scope) | `core` | — |
|
||||
| [`fs-patch`](../packages/fs/fs-patch) | `fs` | — |
|
||||
| [`api-gateway`](../packages/api/gateway) | `api` | — |
|
||||
| [`api-job-controller`](../packages/api/job-controller) | `api` | — |
|
||||
| [`api-remote-access`](../packages/api/remote-access) | `api` | — |
|
||||
|
||||
@@ -65,6 +65,7 @@ flowchart TD
|
||||
pkg_fs["fs"]
|
||||
pkg_fs_local["fs-local"]
|
||||
pkg_fs_observation_policy["fs-observation-policy"]
|
||||
pkg_fs_patch["fs-patch"]
|
||||
pkg_fs_sandbox["fs-sandbox"]
|
||||
pkg_tool_fs["tool-fs"]
|
||||
pkg_tool_fs_search["tool-fs-search"]
|
||||
@@ -1533,6 +1534,7 @@ flowchart TD
|
||||
| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | — |
|
||||
| [`scope`](../packages/core/scope) | `core` | — |
|
||||
| [`fs-patch`](../packages/fs/fs-patch) | `fs` | — |
|
||||
| [`api-gateway`](../packages/api/gateway) | `api` | — |
|
||||
| [`api-job-controller`](../packages/api/job-controller) | `api` | — |
|
||||
| [`api-remote-access`](../packages/api/remote-access) | `api` | — |
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/deliverables.md
|
||||
/deliverables:
|
||||
en: a5ce6d07597594ec
|
||||
zh: 72639c6033d66f41
|
||||
en: b7a2665f103d3e99
|
||||
zh: ee642ed0d3385f67
|
||||
/deliverables/presentedfile-one-declared-delivery:
|
||||
en: bf10a8aa233c61d9
|
||||
zh: fdab894f8448c843
|
||||
|
||||
@@ -6,6 +6,8 @@ What a turn hands to the user, owned by the [deliverables package group](../../p
|
||||
|
||||
Sources: [`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts), [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
|
||||
|
||||
Patch calls use the same summary and comparison APIs. For paths outside Git coverage, a move contributes a deleted source and an added destination; a stopped commit contributes only actual net file changes. The [capture rules](../../packages/deliverables/workspace-changes/README.md#use-this-package) define path selection and observation behavior.
|
||||
|
||||
## `PresentedFile` — one declared delivery
|
||||
|
||||
```ts type-equiv
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
源码:[`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts)、[`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
|
||||
|
||||
补丁调用使用相同的摘要与对比 API。对于 Git 覆盖之外的路径,移动形成源删除与目标新增;停止的提交只贡献实际文件净变化。[采集规则](../../packages/deliverables/workspace-changes/README.zh.md#use-this-package)定义路径选择与观察行为。
|
||||
|
||||
## `PresentedFile`:一条声明的交付
|
||||
|
||||
```ts type-equiv
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md
|
||||
/filesystem:
|
||||
en: 9085200a1975c5cd
|
||||
zh: 48af21e513f7dc25
|
||||
en: 7f7941ec6f70a8b8
|
||||
zh: 450937984b6a8748
|
||||
/filesystem/target-identity-and-metadata-provider-contract:
|
||||
en: 345def8a8e6c58e0
|
||||
zh: 01d6e2f0f22daf9d
|
||||
en: 1e55f1c74f0ade3f
|
||||
zh: faecf389a2fb0a93
|
||||
/filesystem/patch-syntax-records:
|
||||
en: 3ab3a02918d41178
|
||||
zh: 78ff80be8599ba43
|
||||
/filesystem/write-and-edit-guards-provider-contract:
|
||||
en: 067137c8b8181c98
|
||||
zh: 0a9c8282789d147f
|
||||
en: a7c449176367a0d6
|
||||
zh: ecb49a885304db62
|
||||
/filesystem/the-fs-policy-events-provider-contract-vocabulary:
|
||||
en: 5dd0bdff9508350d
|
||||
zh: 74d846f58fe561c9
|
||||
en: b25951ca185a890e
|
||||
zh: 42a1344a2cc860a9
|
||||
/filesystem/execution-context-policy-plugin:
|
||||
en: a23c12fc81bb4786
|
||||
zh: 9dc12d18f9c97e6a
|
||||
@@ -21,14 +24,17 @@
|
||||
en: 9315d84e53db33e9
|
||||
zh: 86426ffbfa487504
|
||||
/filesystem/observed-file-state-policy-plugin:
|
||||
en: 93e8d93a5049b67f
|
||||
zh: b50db8c745baa5fe
|
||||
en: cfa56ed5d1eb2ff9
|
||||
zh: 04be764f3dc8a48c
|
||||
/filesystem/patch-execution-consumer:
|
||||
en: 7b462db42ab89b10
|
||||
zh: 968219ded2b55d69
|
||||
/filesystem/error-taxonomy-provider-contract:
|
||||
en: 0785de8395fef24f
|
||||
zh: 0fc173dff1fe4658
|
||||
en: ca206569d4e2528d
|
||||
zh: e36d70ad39a73570
|
||||
/filesystem/no-timeouts-on-file-io:
|
||||
en: ae18af906f3f7e50
|
||||
zh: 24a1aa6f903fe08d
|
||||
/filesystem/the-service-and-the-plugin:
|
||||
en: 6dcd1f39906d785e
|
||||
zh: e2b4b104bad31144
|
||||
en: a5e2474038c20e73
|
||||
zh: 0b8c7a9f504226b8
|
||||
|
||||
+203
-29
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](filesystem.zh.md)
|
||||
|
||||
The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-observation-policy](../../packages/fs/fs-observation-policy) records observed presence or absence and adds freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas.
|
||||
The optional filesystem capability composes these roles: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and guarded file mutations, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-observation-policy](../../packages/fs/fs-observation-policy) records observed presence or absence and adds freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas. The pure [dsh-fs-patch](../../packages/fs/fs-patch/README.md) library supplies patch parsing and text application.
|
||||
|
||||
`dsh-fs-observation-policy` is optional. Without it, the `FileSystem` Service Definition, a provider, and the `dsh-tool-fs` Consumer form the complete, unconstrained filesystem seam: `write` unconditionally creates or overwrites, and `edit` unconditionally replaces literal text. The policy plugin changes these operations by deciding the `fs/*` waterfalls. Removing it does not break the tool because the tool calls `ctx.fs` and dispatches events; it does not call policy methods. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-observation-policy` so the default behavior is read-before-write/edit.
|
||||
`dsh-fs-observation-policy` is optional. Without it, the `FileSystem` Service Definition, a provider, and the `dsh-tool-fs` Consumer form the complete, unconstrained filesystem seam: `write` unconditionally creates or overwrites, and `edit` unconditionally replaces literal text. The policy plugin changes these operations by deciding the `fs/*` waterfalls. Removing it does not break the tool because the tool calls `ctx.fs` and dispatches events; it does not call policy methods. The shipped compositions run without it: `dsh-base` lists the `fs-observation-policy` row `disabled: true`, and a deployment that wants read-before-write/edit sets that row to `disabled: false`.
|
||||
|
||||
Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/fs-observation-policy/src/types.ts`](../../packages/fs/fs-observation-policy/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts).
|
||||
|
||||
@@ -30,7 +30,7 @@ interface FsTarget {
|
||||
}
|
||||
```
|
||||
|
||||
The backend owns file-version tokens — the freshness token a write/edit guards against. The policy plugin stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings.
|
||||
The backend owns file-version tokens — the freshness token a mutation guards against. The policy plugin stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -111,8 +111,114 @@ interface FsDirEntry {
|
||||
}
|
||||
```
|
||||
|
||||
<a id="patch-syntax-records"></a>
|
||||
## Patch syntax records
|
||||
|
||||
[dsh-fs-patch](../../packages/fs/fs-patch/README.md) parses complete patch text into serializable operations whose paths are unresolved strings, and `applyHunks` applies hunks to caller-supplied LF-normalized text, returning complete new text or a structured match failure. The package README owns the pinned parsing and matching rules.
|
||||
|
||||
```ts type-equiv
|
||||
/** One contiguous update. */
|
||||
interface PatchHunk {
|
||||
/** One-based input line containing the marker or first implicit hunk line. */
|
||||
lineNumber: number
|
||||
/** Text following `@@ `, used as a search anchor by the text matcher. */
|
||||
context?: string
|
||||
/** Context and removed lines, without patch prefixes or line endings. */
|
||||
oldLines: string[]
|
||||
/** Context and added lines, without patch prefixes or line endings. */
|
||||
newLines: string[]
|
||||
/** Whether `*** End of File` follows this hunk. */
|
||||
isEndOfFile: boolean
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Ordered file operation; a move is an update with a destination path. */
|
||||
type PatchOperation =
|
||||
| {
|
||||
kind: 'add'
|
||||
path: string
|
||||
lineNumber: number
|
||||
/** Added lines joined with LF, including a final LF for every added line. */
|
||||
content: string
|
||||
}
|
||||
| {
|
||||
kind: 'delete'
|
||||
path: string
|
||||
lineNumber: number
|
||||
}
|
||||
| {
|
||||
kind: 'update'
|
||||
path: string
|
||||
lineNumber: number
|
||||
moveTo?: string
|
||||
hunks: PatchHunk[]
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** The first hunk whose anchor or old lines could not be located in the original text. */
|
||||
interface HunkApplyFailure {
|
||||
/** One-based line of the failing hunk in the patch input. */
|
||||
hunkLineNumber: number
|
||||
/** The failed anchor or old-content search. */
|
||||
stage: 'context' | 'oldLines'
|
||||
/** Zero-based sequential search cursor; EOF searches override it with the tail candidate. */
|
||||
searchStart: number
|
||||
/** Original anchor or old lines, including any trailing empty line retried without its sentinel. */
|
||||
pattern: string[]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Complete new text or a match failure; failures contain no partially updated text. */
|
||||
type ApplyHunksResult =
|
||||
| { ok: true; after: string }
|
||||
| { ok: false; failure: HunkApplyFailure }
|
||||
```
|
||||
|
||||
## Write and edit guards (provider contract)
|
||||
|
||||
The same optional guard rules cover patches, deletion, and moves. Providers perform no observation recording; callers dispatch intents and record completed outcomes.
|
||||
|
||||
| Operation | Intent | Success observation |
|
||||
|---|---|---|
|
||||
| `patchText` | `fs/edit-intent` | Target present at the returned version. |
|
||||
| `deleteFile` | `fs/delete-intent` | Source absent. |
|
||||
| `moveFile` source | `fs/edit-intent` | Source absent only when `sourceDeleted` is true. |
|
||||
| `moveFile` destination | `fs/write-intent` | Destination present at `destination.version`. |
|
||||
|
||||
Patch, delete, and move sources must be regular files; missing or stale sources report `FS_STALE_VERSION`. Patch matching runs on LF-normalized text inside the mutation lock, and a mismatch reports `FS_PATCH_MISMATCH` with the patch hunk line and stage. Moves reject identical target keys with `FS_SAME_TARGET`, use write guards for their destination, and preserve source line endings and mode. Deletion, move-source, and overwritten-destination bases are bounded nullable LF text. A move is two effects: the destination may be published while source removal fails. Cancellation is checked before local destination staging; after that, the provider completes both effects and reports their outcome.
|
||||
|
||||
Targets already follow symlinks, so a caller that rejects symlink sources inspects the path with `lstat` before `resolve`.
|
||||
|
||||
```ts type-equiv
|
||||
/** Outcome of a guarded delete; the bounded basis follows FsWriteOutcome.before. */
|
||||
interface FsDeleteOutcome {
|
||||
/** LF-normalized removed content, or null for an unavailable, binary, or over-limit basis. */
|
||||
before: string | null
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Serializable failure after a move has published its destination but retained its source. */
|
||||
interface FsMoveSourceFailure {
|
||||
/** Filesystem failure code for source removal. */
|
||||
code: FsErrorCode
|
||||
/** Diagnostic identifying the source and the failed removal. */
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Published destination and removal status; sourceBefore is the bounded LF source basis captured under the move lock. */
|
||||
type FsMoveOutcome =
|
||||
| { sourceBefore: string | null; destination: FsWriteOutcome; sourceDeleted: true }
|
||||
| { sourceBefore: string | null; destination: FsWriteOutcome; sourceDeleted: false; sourceFailure: FsMoveSourceFailure }
|
||||
```
|
||||
|
||||
Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit both use the same optional `expected` field.
|
||||
|
||||
```ts type-equiv
|
||||
@@ -180,9 +286,9 @@ interface FsEditOutcome {
|
||||
|
||||
## The fs policy events (provider contract vocabulary)
|
||||
|
||||
`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-fs-observation-policy`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
|
||||
`dsh-fs` owns four events for mutation consumers and observation policy. Consumers dispatch intents before operations and record completed effects; they do not depend on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
|
||||
|
||||
`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event carrying an `FsObservation`: present at a version or confirmed absent. It is dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener can replace a read error or surface as the tool's `isError` result after a mutation already succeeded. The generated [cordis surface](#cordis-surface) below shows the exact signatures.
|
||||
`fs/write-intent`, `fs/edit-intent`, and `fs/delete-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event carrying an `FsObservation`: present at a version or confirmed absent. It is dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener can replace a read error or surface as the tool's `isError` result after a mutation already succeeded. The generated [cordis surface](#cordis-surface) below shows the exact signatures.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -239,35 +345,37 @@ interface FileReadOutcome {
|
||||
|
||||
## Observed-file state (policy plugin)
|
||||
|
||||
Observed state is a `WeakMap<owner, Map<targetKey, FsObservation>>` held inside the `dsh-fs-observation-policy` plugin. Missing map entry means unseen; `{ kind: 'absent' }` means a `read` or `str_replace_editor` `view`, `str_replace`, or `insert` metadata miss confirmed absence; `{ kind: 'present', version }` means a read, write, or edit observed that version. The write decision maps unseen and absent to `createIfAbsent`, while present maps to `replaceIfVersion`; the edit decision maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. Disposal drops everything (HMR safety), and the policy performs no filesystem I/O.
|
||||
Observed state is a `WeakMap<owner, Map<targetKey, FsObservation>>` held inside the `dsh-fs-observation-policy` plugin. Missing map entry means unseen; `{ kind: 'absent' }` means a `read` or `str_replace_editor` `view`, `str_replace`, or `insert` metadata miss confirmed absence; `{ kind: 'present', version }` means a read, write, or edit observed that version. The write decision maps unseen and absent to `createIfAbsent`, while present maps to `replaceIfVersion`; the edit/delete decision maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. Disposal drops everything (HMR safety), and the policy performs no filesystem I/O.
|
||||
|
||||
## Patch execution (consumer)
|
||||
|
||||
The internal [tool-fs patch executor](../../packages/fs/tool-fs/README.md#understand-the-implementation) dispatches intent decisions in preflight without emitting observations, simulates preceding operations, and reacquires guards for each commit. Sandbox containment stays with the provider at commit, as for `write` and `edit`. Provider results determine which effects are recorded; executor details and failure guarantees belong to the package README.
|
||||
|
||||
| Operation | Preflight intent on first target use | Commit intent | Successful observation |
|
||||
|---|---|---|---|
|
||||
| add | `fs/write-intent` | `fs/write-intent` | target present(version) |
|
||||
| update | `fs/edit-intent` | `fs/edit-intent` | target present(version) |
|
||||
| delete | `fs/delete-intent` | `fs/delete-intent` | target absent |
|
||||
| move | source `fs/edit-intent`; destination `fs/write-intent` | same two intents, freshly dispatched | destination present(version); source absent only when removed |
|
||||
|
||||
The `apply_patch` tool returns strict commit values with per-operation diagnostics and full-text change inputs for rendering; persisted metadata holds diff hunks from the provider's bounded bases and a stopped flag. Syntax and preflight refusals set `isError: true`; commit reports retain `isError: false` even when stopped so known partial diffs reach Session presentation, except that a cancelled call receives the tool registry's abort result like every other tool. The [tool README](../../packages/fs/tool-fs/README.md#use-this-package) owns formatting and recovery.
|
||||
|
||||
## Error taxonomy (provider contract)
|
||||
|
||||
Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`HarnessError`). The tool registry preserves `{ name, code }` on error results, so retry, permission, and UI layers can branch without parsing text.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Stable, machine-routable codes for filesystem failures. Carried on
|
||||
* {@link FsError}; the tool registry exposes `{ name, code }` on `isError`
|
||||
* results so retry/permission/UI layers can branch without parsing messages.
|
||||
*/
|
||||
type FsErrorCode =
|
||||
| 'FS_NOT_FOUND'
|
||||
| 'FS_NOT_DIRECTORY'
|
||||
| 'FS_NOT_TEXT'
|
||||
| 'FS_NOT_REGULAR_FILE'
|
||||
| 'FS_TOO_LARGE'
|
||||
| 'FS_PERMISSION_DENIED'
|
||||
| 'FS_SANDBOX_DENIED'
|
||||
| 'FS_IO_ERROR'
|
||||
| 'FS_STALE_VERSION'
|
||||
| 'FS_NOT_OBSERVED'
|
||||
| 'FS_AMBIGUOUS_EDIT'
|
||||
| 'FS_EDIT_NOT_FOUND'
|
||||
| 'FS_ABORTED'
|
||||
`FS_PATCH_MISMATCH` means a hunk failed inside the provider commit; its message names the patch line and failed search stage. `FS_SAME_TARGET` means a move source and destination share the resolved identity. Delete intent follows edit intent: unseen is `FS_NOT_OBSERVED`, observed absence is `FS_NOT_FOUND`, and present state supplies a version. Provider-side missing patch/delete/move sources use `FS_STALE_VERSION` even without a guard.
|
||||
|
||||
```ts
|
||||
import { FS_ERROR_CODES } from '@deepseek-ai/dsh-fs'
|
||||
```
|
||||
|
||||
`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a write/edit — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`.
|
||||
```ts type-equiv
|
||||
/** One machine-routable filesystem failure code from FS_ERROR_CODES. */
|
||||
type FsErrorCode = typeof FS_ERROR_CODES[number]
|
||||
```
|
||||
|
||||
`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a mutation — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`.
|
||||
|
||||
## No timeouts on file IO
|
||||
|
||||
@@ -275,7 +383,7 @@ type FsErrorCode =
|
||||
|
||||
## The service and the plugin
|
||||
|
||||
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `processPathFromHostPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `readBytes`, `listDir`, `writeText`, and `editText`. `dsh-fs-observation-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures.
|
||||
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `processPathFromHostPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `readBytes`, `listDir`, `writeText`, `editText`, `patchText`, `deleteFile`, and `moveFile`. `dsh-fs-observation-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit/delete intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -289,7 +397,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.fs` — `FileSystem` (abstract seam)
|
||||
|
||||
Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.
|
||||
Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and single-file publication is atomic. Moves can retain their source after publishing the destination. Optional guards add stale protection without changing the unguarded provider contract.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -458,6 +566,53 @@ abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent,
|
||||
* @returns the outcome, including the version the edit produced.
|
||||
*/
|
||||
abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsEditOutcome>
|
||||
|
||||
/**
|
||||
* Atomically apply parsed hunks to the LF-normalized text and restore the
|
||||
* stored line-ending style. A missing or stale target reports
|
||||
* `FS_STALE_VERSION`; an unmatched hunk reports `FS_PATCH_MISMATCH`.
|
||||
* @param target - the resolved regular file to patch.
|
||||
* @param hunks - ordered hunks from `parsePatch`.
|
||||
* @param expected - the version guard; omit for an unconditional patch.
|
||||
* @param signal - aborts before atomic publication takes effect.
|
||||
* @param sandboxPolicy - the per-call mode and workspace root this patch runs
|
||||
* under; a sandboxing backend fences the patch by it, the bare backend
|
||||
* ignores it. Omit to leave the backend its own default.
|
||||
* @returns the outcome, including the version the patch produced.
|
||||
*/
|
||||
abstract patchText( target: FsTarget, hunks: readonly PatchHunk[], expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsEditOutcome>
|
||||
|
||||
/**
|
||||
* Remove a regular file. A missing or stale target reports `FS_STALE_VERSION`;
|
||||
* a directory or other non-file reports `FS_NOT_REGULAR_FILE`. Resolution has
|
||||
* already followed symlinks, so the target is the real file.
|
||||
* @param target - the resolved file to remove.
|
||||
* @param expected - the version guard; omit for an unconditional removal.
|
||||
* @param signal - aborts before removal.
|
||||
* @param sandboxPolicy - the per-call mode and workspace root this removal
|
||||
* runs under; a sandboxing backend fences it, the bare backend ignores it.
|
||||
* Omit to leave the backend its own default.
|
||||
* @returns the bounded LF-normalized removed text, or null when no basis is available.
|
||||
*/
|
||||
abstract deleteFile( target: FsTarget, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsDeleteOutcome>
|
||||
|
||||
/**
|
||||
* Publish the source text, optionally patched, at the destination, then remove
|
||||
* the source. Both targets stay locked in canonical order. Identical targets
|
||||
* report `FS_SAME_TARGET`; the source guard follows `patchText` and the
|
||||
* destination guard follows `writeText`. The two effects are separate: a
|
||||
* published destination with a retained source is a final outcome reported
|
||||
* through `sourceDeleted: false`.
|
||||
* @param source - the resolved regular source; its line-ending style and mode carry over.
|
||||
* @param destination - the resolved destination within the same provider.
|
||||
* @param options - optional hunks and the source/destination guards; an omitted guard is unconditional.
|
||||
* @param signal - checked before destination staging; the commit then completes both effects.
|
||||
* @param sandboxPolicy - the per-call mode and workspace root both paths run
|
||||
* under; a sandboxing backend fences them, the bare backend ignores it.
|
||||
* Omit to leave the backend its own default.
|
||||
* @returns the bounded LF source basis, the destination outcome, and the source-removal status.
|
||||
*/
|
||||
abstract moveFile( source: FsTarget, destination: FsTarget, options: { hunks?: readonly PatchHunk[] expectedSource?: { version: FsVersion } expectedDestination?: FsWriteIntent }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsMoveOutcome>
|
||||
```
|
||||
|
||||
Types: [SandboxExecutionPolicy](sandbox.md)
|
||||
@@ -468,6 +623,25 @@ Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
### `fs/*` events
|
||||
|
||||
<a id="fsdelete-intent--waterfall"></a>
|
||||
|
||||
#### `fs/delete-intent` — waterfall
|
||||
|
||||
Single-slot decision for FileSystem.deleteFile; the first returned guard wins. Calling next() delegates to an unconditional deletion.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Single-slot decision for FileSystem.deleteFile; the first returned guard wins.
|
||||
* Calling next() delegates to an unconditional deletion.
|
||||
* @param target - the resolved source about to be removed.
|
||||
* @param actor - the opaque tool-execution context used to select observed state.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/delete-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
```
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
<a id="fsedit-intent--waterfall"></a>
|
||||
|
||||
#### `fs/edit-intent` — waterfall
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](filesystem.md) | 中文
|
||||
|
||||
可选的文件系统能力由四个部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选守卫的原子文本操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-observation-policy](../../packages/fs/fs-observation-policy) 记录观测到的存在或缺失状态,并通过事件(而非服务)添加新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop(智能体循环)主干之外;替换后端不会改变策略或工具 schema。
|
||||
可选的文件系统能力由以下角色组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 和受防护的文件变更操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-observation-policy](../../packages/fs/fs-observation-policy) 记录观测到的存在或缺失状态,并通过事件(而非服务)添加新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop(智能体循环)主干之外;替换后端不会改变策略或工具 schema。纯库 [dsh-fs-patch](../../packages/fs/fs-patch/README.zh.md) 提供补丁解析与文本应用。
|
||||
|
||||
`dsh-fs-observation-policy` 是可选插件。没有该插件时,`FileSystem` 服务定义、一个提供方和 `dsh-tool-fs` 消费方组成完整且不受约束的文件系统 seam:`write` 无条件创建或覆盖,`edit` 无条件替换字面文本。策略插件通过裁决 `fs/*` waterfall(瀑布式事件)来改变这些操作。移除该插件不会破坏工具,因为工具调用 `ctx.fs` 并分发事件,而不调用策略方法。加载了 `dsh-tool-fs` 的部署也应加载 `dsh-fs-observation-policy`,使默认行为为「先读后写/编辑」。
|
||||
`dsh-fs-observation-policy` 是可选插件。没有该插件时,`FileSystem` 服务定义、一个提供方和 `dsh-tool-fs` 消费方组成完整且不受约束的文件系统 seam:`write` 无条件创建或覆盖,`edit` 无条件替换字面文本。策略插件通过裁决 `fs/*` waterfall(瀑布式事件)来改变这些操作。移除该插件不会破坏工具,因为工具调用 `ctx.fs` 并分发事件,而不调用策略方法。随发行版交付的组合不启用它:`dsh-base` 中的 `fs-observation-policy` 行写着 `disabled: true`,需要「先读后写/编辑」的部署把该行改为 `disabled: false`。
|
||||
|
||||
提供方源码:[`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) 与 [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)。策略源码:[`packages/fs/fs-observation-policy/src/types.ts`](../../packages/fs/fs-observation-policy/src/types.ts)。读取渲染源码:[`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts)。
|
||||
|
||||
@@ -30,7 +30,7 @@ interface FsTarget {
|
||||
}
|
||||
```
|
||||
|
||||
后端拥有文件版本 token,即 write/edit 所守卫的新鲜度 token。策略插件存储它们以进行陈旧检查;消费方不解释其内容。两个 id 都是品牌化的不透明字符串。
|
||||
后端拥有文件版本 token,即变更所守卫的新鲜度 token。策略插件存储它们以进行陈旧检查;消费方不解释其内容。两个 id 都是品牌化的不透明字符串。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -111,8 +111,114 @@ interface FsDirEntry {
|
||||
}
|
||||
```
|
||||
|
||||
<a id="patch-syntax-records"></a>
|
||||
## 补丁语法记录
|
||||
|
||||
[dsh-fs-patch](../../packages/fs/fs-patch/README.zh.md) 将完整补丁文本解析为可序列化的操作,路径保持为未解析的字符串;`applyHunks` 将 hunk 应用于调用方提供的 LF 规范化文本,返回完整新文本或结构化匹配失败。固定的解析与匹配规则由包 README 维护。
|
||||
|
||||
```ts type-equiv
|
||||
/** One contiguous update. */
|
||||
interface PatchHunk {
|
||||
/** One-based input line containing the marker or first implicit hunk line. */
|
||||
lineNumber: number
|
||||
/** Text following `@@ `, used as a search anchor by the text matcher. */
|
||||
context?: string
|
||||
/** Context and removed lines, without patch prefixes or line endings. */
|
||||
oldLines: string[]
|
||||
/** Context and added lines, without patch prefixes or line endings. */
|
||||
newLines: string[]
|
||||
/** Whether `*** End of File` follows this hunk. */
|
||||
isEndOfFile: boolean
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Ordered file operation; a move is an update with a destination path. */
|
||||
type PatchOperation =
|
||||
| {
|
||||
kind: 'add'
|
||||
path: string
|
||||
lineNumber: number
|
||||
/** Added lines joined with LF, including a final LF for every added line. */
|
||||
content: string
|
||||
}
|
||||
| {
|
||||
kind: 'delete'
|
||||
path: string
|
||||
lineNumber: number
|
||||
}
|
||||
| {
|
||||
kind: 'update'
|
||||
path: string
|
||||
lineNumber: number
|
||||
moveTo?: string
|
||||
hunks: PatchHunk[]
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** The first hunk whose anchor or old lines could not be located in the original text. */
|
||||
interface HunkApplyFailure {
|
||||
/** One-based line of the failing hunk in the patch input. */
|
||||
hunkLineNumber: number
|
||||
/** The failed anchor or old-content search. */
|
||||
stage: 'context' | 'oldLines'
|
||||
/** Zero-based sequential search cursor; EOF searches override it with the tail candidate. */
|
||||
searchStart: number
|
||||
/** Original anchor or old lines, including any trailing empty line retried without its sentinel. */
|
||||
pattern: string[]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Complete new text or a match failure; failures contain no partially updated text. */
|
||||
type ApplyHunksResult =
|
||||
| { ok: true; after: string }
|
||||
| { ok: false; failure: HunkApplyFailure }
|
||||
```
|
||||
|
||||
## 写入与编辑守卫(提供方约定)
|
||||
|
||||
相同的可选防护规则也覆盖补丁、删除和移动。提供方不记录观察;调用方分派意图并记录已完成的结果。
|
||||
|
||||
| 操作 | 意图 | 成功观察 |
|
||||
|---|---|---|
|
||||
| `patchText` | `fs/edit-intent` | 目标存在于返回的版本。 |
|
||||
| `deleteFile` | `fs/delete-intent` | 源缺失。 |
|
||||
| `moveFile` 源 | `fs/edit-intent` | 仅在 `sourceDeleted` 为 true 时记录源缺失。 |
|
||||
| `moveFile` 目标 | `fs/write-intent` | 目标存在于 `destination.version`。 |
|
||||
|
||||
补丁、删除和移动的源必须是普通文件;源缺失或版本陈旧报告 `FS_STALE_VERSION`。补丁在变更锁内对 LF 规范化文本匹配,不匹配报告 `FS_PATCH_MISMATCH`,包含补丁 hunk 行号与阶段。移动以 `FS_SAME_TARGET` 拒绝相同目标 key,对目标使用写入防护,并保留源的行尾风格与模式。删除、移动源和被覆盖目标的基线是有界、可空的 LF 文本。移动是两个效果:目标可能已发布而源删除失败。本地移动在暂存目标前检查取消;随后提供方完成两端效果并报告结果。
|
||||
|
||||
目标已跟随符号链接,因此拒绝符号链接源的调用方在 `resolve` 前用 `lstat` 检查路径。
|
||||
|
||||
```ts type-equiv
|
||||
/** Outcome of a guarded delete; the bounded basis follows FsWriteOutcome.before. */
|
||||
interface FsDeleteOutcome {
|
||||
/** LF-normalized removed content, or null for an unavailable, binary, or over-limit basis. */
|
||||
before: string | null
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Serializable failure after a move has published its destination but retained its source. */
|
||||
interface FsMoveSourceFailure {
|
||||
/** Filesystem failure code for source removal. */
|
||||
code: FsErrorCode
|
||||
/** Diagnostic identifying the source and the failed removal. */
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Published destination and removal status; sourceBefore is the bounded LF source basis captured under the move lock. */
|
||||
type FsMoveOutcome =
|
||||
| { sourceBefore: string | null; destination: FsWriteOutcome; sourceDeleted: true }
|
||||
| { sourceBefore: string | null; destination: FsWriteOutcome; sourceDeleted: false; sourceFailure: FsMoveSourceFailure }
|
||||
```
|
||||
|
||||
`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 都使用同一个可选的 `expected` 字段。
|
||||
|
||||
```ts type-equiv
|
||||
@@ -180,9 +286,9 @@ interface FsEditOutcome {
|
||||
|
||||
## fs 策略事件(提供方约定词汇)
|
||||
|
||||
`dsh-fs` 拥有三个事件,由工具分发、策略插件监听,使事件发出方(`dsh-tool-fs`)与监听方(`dsh-fs-observation-policy`)共享词汇,而事件发出方无需依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,也不含 agent/会话所有者结构。
|
||||
`dsh-fs` 为变更消费方与观察策略定义四个事件。消费方在操作前分发意图,并记录已完成的效果,无需依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,也不含 agent/会话所有者结构。
|
||||
|
||||
`fs/write-intent` 与 `fs/edit-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该 slot 按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,携带 `FsObservation`:存在于某个版本,或确认缺失。该事件通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不会捕获该 emit 抛出的异常——抛出异常的监听方可能取代读取操作原本待返回的错误,或使工具在变更已经成功后返回 `isError` 结果。下方生成的 [cordis surface](#cordis-surface) 展示确切签名。
|
||||
`fs/write-intent`、`fs/edit-intent` 与 `fs/delete-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该 slot 按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,携带 `FsObservation`:存在于某个版本,或确认缺失。该事件通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不会捕获该 emit 抛出的异常——抛出异常的监听方可能取代读取操作原本待返回的错误,或使工具在变更已经成功后返回 `isError` 结果。下方生成的 [cordis surface](#cordis-surface) 展示确切签名。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -239,35 +345,37 @@ interface FileReadOutcome {
|
||||
|
||||
## 已观测文件状态(策略插件)
|
||||
|
||||
已观测状态是 `dsh-fs-observation-policy` 插件内部持有的 `WeakMap<owner, Map<targetKey, FsObservation>>`。映射中没有条目表示未见;`{ kind: 'absent' }` 表示 `read` 的元数据未命中,或 `str_replace_editor` 的 `view`、`str_replace`、`insert` 命令发生元数据未命中,从而确认缺失;`{ kind: 'present', version }` 表示 read、write 或 edit 观测到该版本。写入决策把未见和缺失映射到 `createIfAbsent`,把存在映射到 `replaceIfVersion`;编辑决策把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。dispose(资源释放)时丢弃全部数据(HMR(热模块替换)安全),策略不执行任何文件系统 I/O。
|
||||
已观测状态是 `dsh-fs-observation-policy` 插件内部持有的 `WeakMap<owner, Map<targetKey, FsObservation>>`。映射中没有条目表示未见;`{ kind: 'absent' }` 表示 `read` 的元数据未命中,或 `str_replace_editor` 的 `view`、`str_replace`、`insert` 命令发生元数据未命中,从而确认缺失;`{ kind: 'present', version }` 表示 read、write 或 edit 观测到该版本。写入决策把未见和缺失映射到 `createIfAbsent`,把存在映射到 `replaceIfVersion`;编辑/删除决策把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。dispose(资源释放)时丢弃全部数据(HMR(热模块替换)安全),策略不执行任何文件系统 I/O。
|
||||
|
||||
## 补丁执行(消费方)
|
||||
|
||||
内部 [tool-fs 补丁执行器](../../packages/fs/tool-fs/README.zh.md#understand-the-implementation) 在预检时分发意图决策而不发出观察、模拟前序操作,并在每次提交时重新取得防护。沙箱围栏与 `write`、`edit` 一样由提供方在提交时判断。提供方结果决定记录哪些效果;执行器细节与失败保证由包 README 维护。
|
||||
|
||||
| 操作 | 首次使用目标的预检意图 | 提交意图 | 成功观察 |
|
||||
|---|---|---|---|
|
||||
| add | `fs/write-intent` | `fs/write-intent` | 目标 present(version) |
|
||||
| update | `fs/edit-intent` | `fs/edit-intent` | 目标 present(version) |
|
||||
| delete | `fs/delete-intent` | `fs/delete-intent` | 目标 absent |
|
||||
| move | 源 `fs/edit-intent`;目标 `fs/write-intent` | 重新分发同一组意图 | 目标 present(version);仅在源删除成功时记录 absent |
|
||||
|
||||
`apply_patch` 工具返回严格的提交值,包含各操作诊断及用于渲染的完整文本变更输入;持久化元数据只保留基于提供方有界基线的 diff hunk 与停止标志。语法和预检拒绝为 `isError: true`;停止的提交报告仍为 `isError: false`,确保已知部分 diff 进入 Session 展示,但被取消的调用与其他工具一样收到工具注册表的中止结果。[工具 README](../../packages/fs/tool-fs/README.zh.md#use-this-package) 维护格式与恢复规则。
|
||||
|
||||
## 错误分类体系(提供方约定)
|
||||
|
||||
文件系统故障使用稳定的 `FsErrorCode` 字符串,由 `FsError`(`HarnessError`)携带。工具注册表在错误结果上保留 `{ name, code }`,使重试、权限和 UI 层可以按 code 分支而无需解析文本。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Stable, machine-routable codes for filesystem failures. Carried on
|
||||
* {@link FsError}; the tool registry exposes `{ name, code }` on `isError`
|
||||
* results so retry/permission/UI layers can branch without parsing messages.
|
||||
*/
|
||||
type FsErrorCode =
|
||||
| 'FS_NOT_FOUND'
|
||||
| 'FS_NOT_DIRECTORY'
|
||||
| 'FS_NOT_TEXT'
|
||||
| 'FS_NOT_REGULAR_FILE'
|
||||
| 'FS_TOO_LARGE'
|
||||
| 'FS_PERMISSION_DENIED'
|
||||
| 'FS_SANDBOX_DENIED'
|
||||
| 'FS_IO_ERROR'
|
||||
| 'FS_STALE_VERSION'
|
||||
| 'FS_NOT_OBSERVED'
|
||||
| 'FS_AMBIGUOUS_EDIT'
|
||||
| 'FS_EDIT_NOT_FOUND'
|
||||
| 'FS_ABORTED'
|
||||
`FS_PATCH_MISMATCH` 表示 hunk 在提供方提交阶段未匹配;消息标明补丁行号与失败的搜索阶段。`FS_SAME_TARGET` 表示移动源与目标共享已解析身份。删除意图遵循编辑意图:未见为 `FS_NOT_OBSERVED`,观察到缺失为 `FS_NOT_FOUND`,存在状态提供版本。提供方收到缺失的补丁/删除/移动源时,即使没有防护也使用 `FS_STALE_VERSION`。
|
||||
|
||||
```ts
|
||||
import { FS_ERROR_CODES } from '@deepseek-ai/dsh-fs'
|
||||
```
|
||||
|
||||
目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了写入/编辑——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观测记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观测到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。
|
||||
```ts type-equiv
|
||||
/** One machine-routable filesystem failure code from FS_ERROR_CODES. */
|
||||
type FsErrorCode = typeof FS_ERROR_CODES[number]
|
||||
```
|
||||
|
||||
目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了变更——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观测记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观测到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。
|
||||
|
||||
## 文件 IO 不设超时
|
||||
|
||||
@@ -275,7 +383,7 @@ type FsErrorCode =
|
||||
|
||||
## 服务与插件
|
||||
|
||||
`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`processPathFromHostPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-observation-policy` **不注册服务**。它通过 `fs/*` 事件门禁添加策略,根据未见、缺失或存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取、写入或编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。
|
||||
`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`processPathFromHostPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText`、`editText`、`patchText`、`deleteFile` 与 `moveFile`。`dsh-fs-observation-policy` **不注册服务**。它通过 `fs/*` 事件门禁添加策略,根据未见、缺失或存在状态对写入、编辑与删除意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取、写入或编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -289,7 +397,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.fs` — `FileSystem` (abstract seam)
|
||||
|
||||
Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.
|
||||
Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and single-file publication is atomic. Moves can retain their source after publishing the destination. Optional guards add stale protection without changing the unguarded provider contract.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -458,6 +566,53 @@ abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent,
|
||||
* @returns the outcome, including the version the edit produced.
|
||||
*/
|
||||
abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsEditOutcome>
|
||||
|
||||
/**
|
||||
* Atomically apply parsed hunks to the LF-normalized text and restore the
|
||||
* stored line-ending style. A missing or stale target reports
|
||||
* `FS_STALE_VERSION`; an unmatched hunk reports `FS_PATCH_MISMATCH`.
|
||||
* @param target - the resolved regular file to patch.
|
||||
* @param hunks - ordered hunks from `parsePatch`.
|
||||
* @param expected - the version guard; omit for an unconditional patch.
|
||||
* @param signal - aborts before atomic publication takes effect.
|
||||
* @param sandboxPolicy - the per-call mode and workspace root this patch runs
|
||||
* under; a sandboxing backend fences the patch by it, the bare backend
|
||||
* ignores it. Omit to leave the backend its own default.
|
||||
* @returns the outcome, including the version the patch produced.
|
||||
*/
|
||||
abstract patchText( target: FsTarget, hunks: readonly PatchHunk[], expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsEditOutcome>
|
||||
|
||||
/**
|
||||
* Remove a regular file. A missing or stale target reports `FS_STALE_VERSION`;
|
||||
* a directory or other non-file reports `FS_NOT_REGULAR_FILE`. Resolution has
|
||||
* already followed symlinks, so the target is the real file.
|
||||
* @param target - the resolved file to remove.
|
||||
* @param expected - the version guard; omit for an unconditional removal.
|
||||
* @param signal - aborts before removal.
|
||||
* @param sandboxPolicy - the per-call mode and workspace root this removal
|
||||
* runs under; a sandboxing backend fences it, the bare backend ignores it.
|
||||
* Omit to leave the backend its own default.
|
||||
* @returns the bounded LF-normalized removed text, or null when no basis is available.
|
||||
*/
|
||||
abstract deleteFile( target: FsTarget, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsDeleteOutcome>
|
||||
|
||||
/**
|
||||
* Publish the source text, optionally patched, at the destination, then remove
|
||||
* the source. Both targets stay locked in canonical order. Identical targets
|
||||
* report `FS_SAME_TARGET`; the source guard follows `patchText` and the
|
||||
* destination guard follows `writeText`. The two effects are separate: a
|
||||
* published destination with a retained source is a final outcome reported
|
||||
* through `sourceDeleted: false`.
|
||||
* @param source - the resolved regular source; its line-ending style and mode carry over.
|
||||
* @param destination - the resolved destination within the same provider.
|
||||
* @param options - optional hunks and the source/destination guards; an omitted guard is unconditional.
|
||||
* @param signal - checked before destination staging; the commit then completes both effects.
|
||||
* @param sandboxPolicy - the per-call mode and workspace root both paths run
|
||||
* under; a sandboxing backend fences them, the bare backend ignores it.
|
||||
* Omit to leave the backend its own default.
|
||||
* @returns the bounded LF source basis, the destination outcome, and the source-removal status.
|
||||
*/
|
||||
abstract moveFile( source: FsTarget, destination: FsTarget, options: { hunks?: readonly PatchHunk[] expectedSource?: { version: FsVersion } expectedDestination?: FsWriteIntent }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsMoveOutcome>
|
||||
```
|
||||
|
||||
Types: [SandboxExecutionPolicy](sandbox.zh.md)
|
||||
@@ -468,6 +623,25 @@ Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
### `fs/*` events
|
||||
|
||||
<a id="fsdelete-intent--waterfall"></a>
|
||||
|
||||
#### `fs/delete-intent` — waterfall
|
||||
|
||||
Single-slot decision for FileSystem.deleteFile; the first returned guard wins. Calling next() delegates to an unconditional deletion.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Single-slot decision for FileSystem.deleteFile; the first returned guard wins.
|
||||
* Calling next() delegates to an unconditional deletion.
|
||||
* @param target - the resolved source about to be removed.
|
||||
* @param actor - the opaque tool-execution context used to select observed state.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/delete-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
```
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
<a id="fsedit-intent--waterfall"></a>
|
||||
|
||||
#### `fs/edit-intent` — waterfall
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: d48814d3ab0e997f
|
||||
zh: d0337d8eba097a63
|
||||
/session-projections/the-snapshot-and-the-change-feed:
|
||||
en: 630c1041f41f89bf
|
||||
zh: a3361bdead116cd7
|
||||
en: bc9d438b90fc2baa
|
||||
zh: df7cbea2dad3786b
|
||||
/session-projections/summary-reads:
|
||||
en: d2bc9bcc775acfb7
|
||||
zh: b1d2ba74496305b8
|
||||
|
||||
@@ -135,7 +135,22 @@ type ProjectionChangeListener = (
|
||||
) => void
|
||||
```
|
||||
|
||||
`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `handle.stateOf(session)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes.
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Event-level change-feed listener: one committed event changed the raw `view`
|
||||
* result of at least one client-visible unit by `Object.is` for one session.
|
||||
* `changes` holds one entry per such unit in registration order, each with the
|
||||
* unit's `key` and its schema-validated view as `val`; `seq` is the seq of the
|
||||
* causing event.
|
||||
*/
|
||||
type ProjectionEventChangeListener = (
|
||||
session: Session,
|
||||
changes: readonly { readonly key: Extract<keyof SessionProjectionMap, string>; readonly val: unknown }[],
|
||||
seq: SessionSeq,
|
||||
) => void
|
||||
```
|
||||
|
||||
`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `handle.stateOf(session)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. After every unit has been driven for one committed event, the event-level feed calls each `onEventChanged` listener once with every client-visible key whose view the event changed; an event that changes no client view makes no event-level call.
|
||||
|
||||
## Summary reads
|
||||
|
||||
@@ -346,6 +361,18 @@ register<K extends string, S>( definition: ProjectionDefinition<K, S> & { key: E
|
||||
*/
|
||||
onChanged(listener: ProjectionChangeListener): () => void
|
||||
|
||||
/**
|
||||
* Subscribe to the event-level change feed. The registration is an effect on
|
||||
* the calling context's fiber. The listener runs synchronously inside the
|
||||
* committed event's `session/event` handling, once after every unit has been
|
||||
* driven and after every {@link onChanged} call for that event. If a unit or
|
||||
* an {@link onChanged} listener throws during that drive, the listener still
|
||||
* receives the changes made before the throw.
|
||||
* @param listener - called once per committed event that changed at least one client-visible unit's raw view by `Object.is`.
|
||||
* @returns the exact disposer that unsubscribes.
|
||||
*/
|
||||
onEventChanged(listener: ProjectionEventChangeListener): () => void
|
||||
|
||||
/**
|
||||
* One consistent cut over every registered client-visible unit for one session, read from
|
||||
* the watermark cache (missing cells fold lazily over the in-memory log).
|
||||
|
||||
@@ -135,7 +135,22 @@ type ProjectionChangeListener = (
|
||||
) => void
|
||||
```
|
||||
|
||||
`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`handle.stateOf(session)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Event-level change-feed listener: one committed event changed the raw `view`
|
||||
* result of at least one client-visible unit by `Object.is` for one session.
|
||||
* `changes` holds one entry per such unit in registration order, each with the
|
||||
* unit's `key` and its schema-validated view as `val`; `seq` is the seq of the
|
||||
* causing event.
|
||||
*/
|
||||
type ProjectionEventChangeListener = (
|
||||
session: Session,
|
||||
changes: readonly { readonly key: Extract<keyof SessionProjectionMap, string>; readonly val: unknown }[],
|
||||
seq: SessionSeq,
|
||||
) => void
|
||||
```
|
||||
|
||||
`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`handle.stateOf(session)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。一个已提交事件驱动完所有单元后,事件级变更流以该事件改变了视图的全部客户端可见 key 调用每个 `onEventChanged` 监听器一次;未改变任何客户端视图的事件不产生事件级调用。
|
||||
|
||||
## 摘要读取
|
||||
|
||||
@@ -346,6 +361,18 @@ register<K extends string, S>( definition: ProjectionDefinition<K, S> & { key: E
|
||||
*/
|
||||
onChanged(listener: ProjectionChangeListener): () => void
|
||||
|
||||
/**
|
||||
* Subscribe to the event-level change feed. The registration is an effect on
|
||||
* the calling context's fiber. The listener runs synchronously inside the
|
||||
* committed event's `session/event` handling, once after every unit has been
|
||||
* driven and after every {@link onChanged} call for that event. If a unit or
|
||||
* an {@link onChanged} listener throws during that drive, the listener still
|
||||
* receives the changes made before the throw.
|
||||
* @param listener - called once per committed event that changed at least one client-visible unit's raw view by `Object.is`.
|
||||
* @returns the exact disposer that unsubscribes.
|
||||
*/
|
||||
onEventChanged(listener: ProjectionEventChangeListener): () => void
|
||||
|
||||
/**
|
||||
* One consistent cut over every registered client-visible unit for one session, read from
|
||||
* the watermark cache (missing cells fold lazily over the in-memory log).
|
||||
|
||||
@@ -998,10 +998,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
@Remote({ mode: 'stream' }) observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable<SessionEventFrame>
|
||||
|
||||
/**
|
||||
* Subscribe to sparse Session projection changes.
|
||||
* Subscribe to Session projection changes.
|
||||
* @param request - target Session.
|
||||
* @param signal - subscription cancellation.
|
||||
* @returns readiness and sparse projection changes.
|
||||
* @returns readiness followed by one projection change frame per committed event.
|
||||
*/
|
||||
@Remote({ mode: 'stream' }) observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable<SessionProjectionFrame>
|
||||
|
||||
|
||||
@@ -1002,10 +1002,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
@Remote({ mode: 'stream' }) observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable<SessionEventFrame>
|
||||
|
||||
/**
|
||||
* Subscribe to sparse Session projection changes.
|
||||
* Subscribe to Session projection changes.
|
||||
* @param request - target Session.
|
||||
* @param signal - subscription cancellation.
|
||||
* @returns readiness and sparse projection changes.
|
||||
* @returns readiness followed by one projection change frame per committed event.
|
||||
*/
|
||||
@Remote({ mode: 'stream' }) observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable<SessionProjectionFrame>
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 7ad02b2146520e7b
|
||||
zh: 93cb93c7aa12ceb4
|
||||
/system-prompt-assembly/assembly-context:
|
||||
en: a5d904293afc7fcb
|
||||
zh: 555883610ac09646
|
||||
en: 26585a8664060a19
|
||||
zh: 2bbe792ef28f40e3
|
||||
/system-prompt-assembly/tool-provider-result:
|
||||
en: 89cb015bf2e3e7a5
|
||||
zh: b4340e853b34c777
|
||||
|
||||
@@ -23,6 +23,8 @@ interface AssembleContext {
|
||||
}
|
||||
```
|
||||
|
||||
`TOOL_APPLY_PATCH` takes order 1350 between `TOOL_EDIT` (1300) and `TOOL_GLOB` (1400).
|
||||
|
||||
## Tool-provider result
|
||||
|
||||
`ToolProviderResult.schemas` is the model-visible set for the current assembly. `knownNames` is the provider's pre-restriction name universe used to distinguish a configured-name typo from a known tool that is deliberately hidden in this scope.
|
||||
|
||||
@@ -23,6 +23,8 @@ interface AssembleContext {
|
||||
}
|
||||
```
|
||||
|
||||
`TOOL_APPLY_PATCH` 的顺序为 1350,位于 `TOOL_EDIT`(1300)与 `TOOL_GLOB`(1400)之间。
|
||||
|
||||
## 工具提供方结果
|
||||
|
||||
`ToolProviderResult.schemas` 是当前组装中对模型可见的工具 schema 集合。`knownNames` 是提供方在限制前的名称全集,用于区分「配置名拼写错误」与「已知工具在此作用域中被有意隐藏」。
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 6cc2cd9ced70bb09
|
||||
zh: 1c0e6cb4ba2ed637
|
||||
/tool-schema-catalog/tool-package-map:
|
||||
en: db58fe23a6ae897f
|
||||
zh: 49ad708d251d5e92
|
||||
en: fea9e47a96157b9d
|
||||
zh: 58a3aa6f22d87ac3
|
||||
/tool-schema-catalog/deepseek-ai-dsh-plugin-manager:
|
||||
en: e0143916bb634762
|
||||
zh: e0143916bb634762
|
||||
@@ -128,6 +128,9 @@
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-fs:
|
||||
en: 07d595ce1f1a5184
|
||||
zh: 07d595ce1f1a5184
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/apply-patch:
|
||||
en: 882f2fa9fac236d6
|
||||
zh: e8320796a5cb038f
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/edit:
|
||||
en: 5b6a252adde51af6
|
||||
zh: 7aa172a5d8b9cbd2
|
||||
@@ -138,8 +141,8 @@
|
||||
en: f6f7a1140066ce04
|
||||
zh: d528e01a0939795a
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/write:
|
||||
en: f80700c5d57e8d87
|
||||
zh: fb7cccbba0d8d486
|
||||
en: 22e7c232e157a1e2
|
||||
zh: 176da52cdd4765a9
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-fs-search:
|
||||
en: 703fea005ce84c50
|
||||
zh: 703fea005ce84c50
|
||||
|
||||
+23
-2
@@ -30,7 +30,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.workingDirectory`, `ctx.tools`, `ctx.terminals`, `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-pwsh-persistent` | `pwsh` | `ctx.workingDirectory`, `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect 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 view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.workingDirectory`, `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (image-tool registration)`, `ctx.llm + an image-capable route (image-tool execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tool is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `apply_patch`, `edit`, `read`, `read_image`, `write` | `ctx.workingDirectory`, `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (image-tool registration)`, `ctx.llm + an image-capable route (image-tool execution)` | `tool/call`, `fs/write-intent, fs/edit-intent, or fs/delete-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); the shipped compositions leave its row disabled, so mutations are unconditional unless a deployment enables it. The image tool is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. `apply_patch` registers only with `applyPatch: true`; the catalog sets it so the schema is documented. `dsh-base` and the Web `standard` preset enable it; the Web `ptc` and `cordis` presets and the PTC overlay state `applyPatch: false`. |
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.workingDirectory`, `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host `rg` install and no shell layer. 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-terminal` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.workingDirectory`, `ctx.tools`, `ctx.terminals`, `ctx.systemPrompt`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot shell/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.jobs`; 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`, `goal/change 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. |
|
||||
@@ -1043,6 +1043,27 @@ Standalone view/create/unique literal replace/line insert tool over the filesyst
|
||||
|
||||
## `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
### `apply_patch`
|
||||
|
||||
Create, update, delete, or move UTF-8 text files with one Codex-style patch; prefer it over multiple edit calls when a change spans several files or separated regions.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "Codex patch text: start with *** Begin Patch and end with *** End Patch. Use *** Add File: path with + lines; *** Update File: path, optionally followed by *** Move to: path, with @@ or @@ context and space-prefixed context, + added, or - removed lines; *** End of File anchors the last hunk to the file end. Use *** Delete File: path to delete. Paths are relative to the Session working directory."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
### `edit`
|
||||
|
||||
Edit an existing UTF-8 text file by replacing literal text.
|
||||
@@ -1154,7 +1175,7 @@ Create or fully replace a UTF-8 text file.
|
||||
|
||||
Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tool is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input.
|
||||
The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); the shipped compositions leave its row disabled, so mutations are unconditional unless a deployment enables it. The image tool is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. `apply_patch` registers only with `applyPatch: true`; the catalog sets it so the schema is documented. `dsh-base` and the Web `standard` preset enable it; the Web `ptc` and `cordis` presets and the PTC overlay state `applyPatch: false`.
|
||||
|
||||
<a id="deepseek-aidsh-tool-fs-search"></a>
|
||||
|
||||
|
||||
+23
-2
@@ -34,7 +34,7 @@
|
||||
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.workingDirectory`、`ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 |
|
||||
| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.workingDirectory`、`ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 |
|
||||
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.workingDirectory`、`ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (image-tool registration)`、`ctx.llm + an image-capable route (image-tool execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时图片工具不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图片输入,否则拒绝。 |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `apply_patch`、`edit`、`read`、`read_image`、`write` | `ctx.workingDirectory`、`ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (image-tool registration)`、`ctx.llm + an image-capable route (image-tool execution)` | `tool/call`、`fs/write-intent, fs/edit-intent, or fs/delete-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。随发行版交付的组合保持该行禁用,因此除非部署启用它,修改都是无条件的。没有 `ctx.attachments` 时图片工具不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图片输入,否则拒绝。`apply_patch` 仅在 `applyPatch: true` 时注册;目录为了记录其 schema 而开启该开关。`dsh-base` 与 Web 的 `standard` preset 开启它;Web 的 `ptc`、`cordis` preset 以及 PTC overlay 显式写出 `applyPatch: false`。 |
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.workingDirectory`、`ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 |
|
||||
| `@deepseek-ai/dsh-tool-terminal` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.workingDirectory`、`ctx.tools`、`ctx.terminals`、`ctx.systemPrompt`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.jobs`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 |
|
||||
| `@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`、`goal/change for mutations`、`tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限;complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 |
|
||||
@@ -1049,6 +1049,27 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
|
||||
|
||||
## `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
### `apply_patch`
|
||||
|
||||
用一个 Codex 风格补丁新增、更新、删除或移动 UTF-8 文本文件;变更跨越多个文件或分离区域时,优先于多次 edit 调用。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "Codex patch text: start with *** Begin Patch and end with *** End Patch. Use *** Add File: path with + lines; *** Update File: path, optionally followed by *** Move to: path, with @@ or @@ context and space-prefixed context, + added, or - removed lines; *** End of File anchors the last hunk to the file end. Use *** Delete File: path to delete. Paths are relative to the Session working directory."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
### `edit`
|
||||
|
||||
通过替换字面量文本来编辑现有 UTF-8 文本文件。
|
||||
@@ -1160,7 +1181,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
|
||||
|
||||
来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时图片工具不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图片输入,否则拒绝。
|
||||
先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。随发行版交付的组合保持该行禁用,因此除非部署启用它,修改都是无条件的。没有 `ctx.attachments` 时图片工具不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图片输入,否则拒绝。`apply_patch` 仅在 `applyPatch: true` 时注册;目录为了记录其 schema 而开启该开关。`dsh-base` 与 Web 的 `standard` preset 开启它;Web 的 `ptc`、`cordis` preset 以及 PTC overlay 显式写出 `applyPatch: false`。
|
||||
|
||||
<a id="deepseek-aidsh-tool-fs-search"></a>
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.4/reasoning-body-props/guide.md
|
||||
/:
|
||||
en: ec5a417eaedb6661
|
||||
zh: b5adde6bf6c32fd0
|
||||
/reasoning-body-slot-text-fields:
|
||||
en: ea09fc75b8c4467e
|
||||
zh: 64d548cbdb42792a
|
||||
/reasoning-body-slot-text-fields/change:
|
||||
en: ff40c45f886d47f5
|
||||
zh: 18d7bb5456b048e6
|
||||
/reasoning-body-slot-text-fields/migration:
|
||||
en: 49cd6281b4aef73a
|
||||
zh: 6bdf66c3f82bfa8b
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Reasoning Body Slot text carries display-ready content, with original output in rawText."
|
||||
---
|
||||
|
||||
# Reasoning Body Slot text fields
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
Contributions to `conversation.chat.reasoning.body` receive the model output in the new `rawText` field. The existing `text` field supplies Chat's prepared display text instead of the original output. `running` is unchanged. This affects custom reasoning renderers and translation wrappers.
|
||||
|
||||
The `conversation.chat.reasoning.content` Factory still accepts `text`, `running`, and optional `labels`. It renders the caller-selected Markdown. The [Chat README](../../../../packages/client/ui-chat/README.md#grouped-rendering) documents the Slot.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Keep reading `text` for ordinary rendering, translation requests, and untranslated views. Update extensions that need the full model output to read `rawText`.
|
||||
2. Pass the selected `text`, or its translation, to the Content Factory without repeating Chat's display processing.
|
||||
3. Rebuild the extension and verify streamed and settled rendering, translation, and the untranslated view with each supported model format.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "推理 Body Slot 的 text 提供显示文本,rawText 保留原始输出。"
|
||||
---
|
||||
|
||||
# 推理 Body Slot 的文本字段
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`conversation.chat.reasoning.body` 扩展通过新增的 `rawText` 字段接收模型输出。现有的 `text` 字段提供 Chat 处理后的显示文本,替代原始输出。`running` 保持不变。这会影响自定义推理渲染器和翻译包装组件。
|
||||
|
||||
`conversation.chat.reasoning.content` Factory 仍接收 `text`、`running` 和可选的 `labels`。它渲染调用方选定的 Markdown。[Chat README](../../../../packages/client/ui-chat/README.zh.md#grouped-rendering) 介绍了该 Slot。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在普通渲染、翻译请求和未翻译视图中继续读取 `text`。需要完整模型输出的扩展改为读取 `rawText`。
|
||||
2. 向 Content Factory 传入选定的 `text` 或其译文,不要重复执行 Chat 的显示处理。
|
||||
3. 重新构建扩展,使用每种支持的模型格式验证流式与完成后的渲染、翻译和未翻译视图。
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/fs-observation-policy-default-off/guide.md
|
||||
/:
|
||||
en: 94b863d7f57ffb37
|
||||
zh: 158af8a6f172ee5f
|
||||
/filesystem-mutations-no-longer-require-a-prior-read-by-default:
|
||||
en: e6dd0c06f99737e4
|
||||
zh: a706b0bbdea94aac
|
||||
/filesystem-mutations-no-longer-require-a-prior-read-by-default/change:
|
||||
en: d7a64f82068f7869
|
||||
zh: 8a42e9c0a56cd3e5
|
||||
/filesystem-mutations-no-longer-require-a-prior-read-by-default/migration:
|
||||
en: c156c11ab737f75a
|
||||
zh: faadea98c8558d07
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The fs-observation-policy row ships disabled, so write, edit, and apply_patch no longer require a prior read, and dsh-base plus the Web standard preset gain apply_patch."
|
||||
---
|
||||
|
||||
# Filesystem mutations no longer require a prior read by default
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
In 0.2.2-alpha.2, `dsh-base` mounted `@deepseek-ai/dsh-fs-observation-policy`, so in every base-backed profile `write` refused to overwrite a file the model had not read, `edit` refused an unread or externally changed file, and the model's guidance named the policy. The `fs-observation-policy` row now ships `disabled: true`: `write` overwrites, `edit` replaces literal text, and the new `apply_patch` applies hunks on context match alone, each unconditionally. The `write` and `edit` guidance still asks for a prior read but no longer cites the policy. Review capture, sandbox confinement, and `fs/observed` events are unchanged.
|
||||
|
||||
The `dsh-base` `tool-fs` row (headless, SDK, and ACP) and the Web `standard` preset now list `apply_patch` (`applyPatch: true`); the Web `ptc` and `cordis` presets keep `applyPatch: false`.
|
||||
|
||||
Affected: the base-backed profiles (`headless`, `web`, `sdk`, `acp`; `sdk-minimal` stacks no `dsh-base`, never mounted the policy, and keeps its text tools off), profile patches that override the `fs-observation-policy` or `tool-fs` rows by id, and recorded expectations that include an `FS_NOT_OBSERVED` or `FS_STALE_VERSION` refusal.
|
||||
|
||||
## Migration
|
||||
|
||||
1. To keep read-before-write behavior, add to the profile's `cordis.patch.yml` (`$DSH_HOME/profiles/<profile>/cordis.patch.yml`):
|
||||
|
||||
```yaml
|
||||
- id: fs-observation-policy
|
||||
disabled: false
|
||||
```
|
||||
|
||||
A profile patch that already carries this row with `disabled: false` needs no change; one that set `disabled: true` to opt out can drop that override.
|
||||
2. To run without `apply_patch`, add `- {id: tool-fs, config: {applyPatch: false}}` to the profile's `cordis.patch.yml` for `headless`, `sdk`, and `acp` (the shipped [PTC overlay](../../../../apps/cli/config/examples/ptc.yml) already does so, matching the Web `ptc` preset); for the Web `standard` preset, edit it from the Agent presets settings page or set `applyPatch: false` on its `tool-fs` row in `packages/bundle/web-app/presets/standard.patch.yml`.
|
||||
3. Confirm with `dsh --profile <profile> --dump-config`: the `fs-observation-policy` row shows the intended `disabled` value, and the `tool-fs` rows show the intended `applyPatch` value. In a session, an `edit` of a file that was not read succeeds with the row disabled and fails with `FS_NOT_OBSERVED` with it enabled.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "fs-observation-policy 行随发行版禁用,write、edit 与 apply_patch 不再要求先读,dsh-base 与 Web standard preset 新增 apply_patch。"
|
||||
---
|
||||
|
||||
# 文件系统修改默认不再要求先读
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
在 0.2.2-alpha.2 中,`dsh-base` 挂载了 `@deepseek-ai/dsh-fs-observation-policy`,因此所有叠加 base 的 profile 中,`write` 拒绝覆盖模型未读过的文件,`edit` 拒绝未读或被外部修改的文件,模型提示语也点名该策略。现在 `fs-observation-policy` 行以 `disabled: true` 交付:`write` 直接覆盖,`edit` 直接替换字面文本,新增的 `apply_patch` 只凭上下文匹配应用 hunk,三者均无条件执行。`write` 与 `edit` 的提示语仍要求先读,但不再引用该策略。Review 采集、沙箱围栏与 `fs/observed` 事件不变。
|
||||
|
||||
`dsh-base` 的 `tool-fs` 行(headless、SDK、ACP)与 Web 的 `standard` preset 现在列出 `apply_patch`(`applyPatch: true`);Web 的 `ptc`、`cordis` preset 保持 `applyPatch: false`。
|
||||
|
||||
受影响范围:叠加 base 的 profile(`headless`、`web`、`sdk`、`acp`;`sdk-minimal` 不叠加 `dsh-base`,从未挂载该策略,文本工具保持关闭),按 id 覆盖 `fs-observation-policy` 或 `tool-fs` 行的 profile patch,以及包含 `FS_NOT_OBSERVED` 或 `FS_STALE_VERSION` 拒绝的录制期望。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 要保留先读后写行为,在 profile 的 `cordis.patch.yml`(`$DSH_HOME/profiles/<profile>/cordis.patch.yml`)中加入:
|
||||
|
||||
```yaml
|
||||
- id: fs-observation-policy
|
||||
disabled: false
|
||||
```
|
||||
|
||||
已带有该行且 `disabled: false` 的 profile patch 无需改动;为退出策略而写 `disabled: true` 的可以删掉该覆盖。
|
||||
2. 要在没有 `apply_patch` 的情况下运行,为 `headless`、`sdk`、`acp` 在 profile 的 `cordis.patch.yml` 中加入 `- {id: tool-fs, config: {applyPatch: false}}`(随发行版交付的 [PTC overlay](../../../../apps/cli/config/examples/ptc.yml) 已这样做,与 Web `ptc` preset 一致);对 Web `standard` preset,在 Agent presets 设置页编辑它,或在 `packages/bundle/web-app/presets/standard.patch.yml` 的 `tool-fs` 行上设置 `applyPatch: false`。
|
||||
3. 用 `dsh --profile <profile> --dump-config` 确认:`fs-observation-policy` 行显示预期的 `disabled` 值,`tool-fs` 行显示预期的 `applyPatch` 值。在会话中,行禁用时对未读文件执行 `edit` 成功,启用时以 `FS_NOT_OBSERVED` 失败。
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/pi-ai-request-image-count/guide.md
|
||||
/:
|
||||
en: 18359b607f378e44
|
||||
zh: c94f503a30b8ebe0
|
||||
/pi-ai-request-image-count:
|
||||
en: 8ed160da9859a7d0
|
||||
zh: 5252360ebf0a2c4d
|
||||
/pi-ai-request-image-count/change:
|
||||
en: c000351899001605
|
||||
zh: fd5048dba5987295
|
||||
/pi-ai-request-image-count/migration:
|
||||
en: 1bdf29eb86d198ca
|
||||
zh: a60f1719dccd6717
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "pi-ai routes now offload the oldest history images once a request carries more than 50 retained images."
|
||||
---
|
||||
|
||||
# pi-ai request image count
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`llm-pi-ai` previously bounded request images only by `maxRequestImageBytes`, so every retained image was resent until that byte bound was reached. Each provider route now also has `maxRequestImages` (default 50) and `imageOffloadCountQuantum` (default 10). When a request carries more retained images than `maxRequestImages`, `dsh-compaction-image-offload` replaces the oldest `imageOffloadCountQuantum` images with placeholder text and retries. The omission is recorded in the Session and does not revert. This affects Sessions with more than 50 images on any pi-ai route, including providers that accept more images per request.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Check the per-request image limit of each pi-ai provider. In the `llm-pi-ai` configuration in `cordis.patch.yml`, set `providers.<provider>.maxRequestImages` to that limit when it is higher than 50.
|
||||
2. To change how many images one overflow offloads, set `providers.<provider>.imageOffloadCountQuantum`; it must not exceed `maxRequestImages`.
|
||||
3. Reopen the profile configuration and confirm the saved values. See the [provider configuration reference](../../../../packages/llm/llm-pi-ai/README.md).
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "当请求保留的图片超过 50 张时,pi-ai 路由现在会省略历史中最老的图片。"
|
||||
---
|
||||
|
||||
# pi-ai 请求图片数量
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`llm-pi-ai` 原先只用 `maxRequestImageBytes` 限制请求图片,因此在达到该字节上限前会重发每张保留图片。现在每条提供方路由还有 `maxRequestImages`(默认 50)和 `imageOffloadCountQuantum`(默认 10)。请求保留的图片多于 `maxRequestImages` 时,`dsh-compaction-image-offload` 把最老的 `imageOffloadCountQuantum` 张图片替换为占位文本并重试。该省略记录在 Session 中,不会回退。这会影响任何 pi-ai 路由上超过 50 张图片的 Session,包括单次请求接受更多图片的提供方。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 核对每个 pi-ai 提供方的单次请求图片上限。上限高于 50 时,在 `cordis.patch.yml` 的 `llm-pi-ai` 配置中把 `providers.<provider>.maxRequestImages` 设为该上限。
|
||||
2. 如需改变每次超限省略的图片数,设置 `providers.<provider>.imageOffloadCountQuantum`;它不得超过 `maxRequestImages`。
|
||||
3. 重新打开 profile 配置,确认保存的数值。参见[提供方配置参考](../../../../packages/llm/llm-pi-ai/README.zh.md)。
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/projection-event-frames/guide.md
|
||||
/:
|
||||
en: e48b8561bd5e8c36
|
||||
zh: 250e816f38495c97
|
||||
/projection-frames-list-every-key-one-event-changed:
|
||||
en: 3f22f3e46a3903db
|
||||
zh: 7694cef52b9aa7f0
|
||||
/projection-frames-list-every-key-one-event-changed/change:
|
||||
en: f0d17c8787e7da13
|
||||
zh: 3b193ae03232b6be
|
||||
/projection-frames-list-every-key-one-event-changed/migration:
|
||||
en: fa9b8d4e4c426754
|
||||
zh: 70f21eb8ec280fdd
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Live Session projection frames list every key one committed event changed instead of carrying one key each."
|
||||
---
|
||||
|
||||
# Projection frames list every key one event changed
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
Live `projection` frames on the `session/control` stream and change frames on the `session/observeProjections` stream carried one projection key each: `{ type: 'projection', sessionId, key, value, seq }`. An event that changed several keys produced several frames with the same `seq`. Each frame now carries every key one committed event changed: `{ type: 'projection', sessionId, seq, changes }` on `session/control`, and the same fields without `sessionId` on `session/observeProjections`. `changes` holds one `{ key, val }` entry per changed key, without duplicate keys, where `val` is the key's new value. A key absent from `changes` is unchanged. The exported types `SessionProjectionUpdate`, `SessionProjectionChange`, `SessionControlFrame`, and `SessionProjectionFrame` of `@deepseek-ai/dsh-api-session-controller` follow the new fields.
|
||||
|
||||
Baseline frames, `session/projections` reads, and the Host listener `ctx.sessionProjections.onChanged` keep their behavior. The Host adds `ctx.sessionProjections.onEventChanged` for one call per committed event.
|
||||
|
||||
This affects Remote clients that read either stream directly, such as test harnesses, proxies, and external dashboards. The bundled Web client and `@deepseek-ai/dsh-api-session-controller/client` read the new frames.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Replace a call such as `apply(frame.key, frame.value, frame.seq)` with a loop over `frame.changes`:
|
||||
|
||||
```ts
|
||||
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller'
|
||||
|
||||
declare const frame: Extract<SessionControlFrame, { type: 'projection' }>
|
||||
declare function apply(key: string, value: unknown, seq: number): void
|
||||
|
||||
for (const change of frame.changes) apply(change.key, change.val, frame.seq)
|
||||
```
|
||||
|
||||
2. Replace key tests such as `frame.key === 'inbox'` with `frame.changes.find(change => change.key === 'inbox')`, and read the value from that entry's `val`.
|
||||
3. Confirm that one committed event yields at most one `projection` frame per Session, and that the values your client holds match a fresh `session/projections` read.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "实时 Session projection 帧改为列出一个已提交事件改变的全部 key,不再每帧只带一个 key。"
|
||||
---
|
||||
|
||||
# Projection 帧列出一个事件改变的全部 key
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`session/control` 流上的实时 `projection` 帧与 `session/observeProjections` 流上的变更帧,原先每帧只带一个 projection key:`{ type: 'projection', sessionId, key, value, seq }`。一个事件改变多个 key 时,会产生多帧 `seq` 相同的帧。现在每帧携带一个已提交事件改变的全部 key:`session/control` 上是 `{ type: 'projection', sessionId, seq, changes }`,`session/observeProjections` 上是去掉 `sessionId` 的同样字段。`changes` 中每个变化的 key 一项 `{ key, val }`,key 不重复,`val` 是该 key 的新值。未出现在 `changes` 中的 key 没有变化。`@deepseek-ai/dsh-api-session-controller` 导出的 `SessionProjectionUpdate`、`SessionProjectionChange`、`SessionControlFrame` 与 `SessionProjectionFrame` 类型随新字段更新。
|
||||
|
||||
baseline 帧、`session/projections` 读取以及 Host 监听器 `ctx.sessionProjections.onChanged` 的行为不变。Host 新增 `ctx.sessionProjections.onEventChanged`,按每个已提交事件调用一次。
|
||||
|
||||
受影响的是直接读取这两条流的 Remote 客户端,例如测试 harness、代理和外部看板。内置 Web 客户端与 `@deepseek-ai/dsh-api-session-controller/client` 已读取新帧。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 把 `apply(frame.key, frame.value, frame.seq)` 这类调用改为遍历 `frame.changes`:
|
||||
|
||||
```ts
|
||||
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller'
|
||||
|
||||
declare const frame: Extract<SessionControlFrame, { type: 'projection' }>
|
||||
declare function apply(key: string, value: unknown, seq: number): void
|
||||
|
||||
for (const change of frame.changes) apply(change.key, change.val, frame.seq)
|
||||
```
|
||||
|
||||
2. 把 `frame.key === 'inbox'` 这类 key 判断改为 `frame.changes.find(change => change.key === 'inbox')`,并从该项的 `val` 读取值。
|
||||
3. 确认一个已提交事件对每个 Session 至多产生一个 `projection` 帧,且客户端持有的值与重新调用 `session/projections` 读到的一致。
|
||||
@@ -618,15 +618,17 @@ export class SessionManager {
|
||||
// ---- Live control and Host-event sinks ----
|
||||
|
||||
/**
|
||||
* Apply a complete control baseline or one later replacement frame.
|
||||
* @param frame - baseline or live control replacement from Session Controller.
|
||||
* Apply a complete control baseline or one later per-event projection frame.
|
||||
* @param frame - baseline or live per-event projection change from Session Controller.
|
||||
*/
|
||||
handleControlFrame(frame: SessionControlFrame): void {
|
||||
if (frame.type === 'baseline') {
|
||||
this.replaceControlBaseline(frame.value)
|
||||
return
|
||||
}
|
||||
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq))
|
||||
const store = this.projectionStore(frame.sessionId)
|
||||
const seq = SessionSeq(frame.seq)
|
||||
for (const change of frame.changes) store.apply(change.key, change.val, seq)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import type {
|
||||
Session, SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type {
|
||||
SessionControlBaseline,
|
||||
SessionControlFrame,
|
||||
SessionProjectionBaseline,
|
||||
SessionProjectionUpdate,
|
||||
SessionProjectionValues,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -19,13 +19,13 @@ export class SessionControlController {
|
||||
|
||||
/** @param ctx - Host context carrying live Agent and projection services. */
|
||||
constructor(private readonly ctx: Context) {
|
||||
ctx.sessionProjections.onChanged((session, key, value, seq) => {
|
||||
ctx.sessionProjections.onEventChanged((session, changes, seq) => {
|
||||
this.broadcast({
|
||||
type: 'projection',
|
||||
sessionId: session.id,
|
||||
key,
|
||||
value: value as JsonValue,
|
||||
seq,
|
||||
// Every projection definition validates its value before change publication.
|
||||
changes: changes as SessionProjectionUpdate['changes'],
|
||||
})
|
||||
})
|
||||
ctx.effect(() => () => {
|
||||
|
||||
@@ -103,10 +103,10 @@ export class HistoryController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to sparse projection replacements without reading a baseline.
|
||||
* Subscribe to projection changes without reading a baseline.
|
||||
* @param request - Session identity to subscribe without reading a baseline.
|
||||
* @param signal - stream cancellation.
|
||||
* @returns readiness followed by sparse per-key replacement values.
|
||||
* @returns readiness followed by one change frame per committed event, listing every key it changed.
|
||||
*/
|
||||
async *observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable<SessionProjectionFrame> {
|
||||
using listener = new ProjectionListener(this.ctx, request.sessionId, signal)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { entryFor } from '../history.ts'
|
||||
import type { SessionProjectionChange, SessionEventEntry } from '../types.ts'
|
||||
|
||||
@@ -128,7 +127,7 @@ export function eventItem(item: EventItem, next?: number): {
|
||||
return { events, next }
|
||||
}
|
||||
|
||||
/** Sparse projection changes retain every key, including keys sharing a seq. */
|
||||
/** Projection changes of one Session, one change per committed event that changed a projection. */
|
||||
export class ProjectionListener implements Disposable {
|
||||
private readonly buffer: ListenerBuffer<{ session: Session; change: SessionProjectionChange }>
|
||||
|
||||
@@ -142,12 +141,12 @@ export class ProjectionListener implements Disposable {
|
||||
const input = this.buffer.createInput<{ session: Session; change: SessionProjectionChange }>(item => [item])
|
||||
const target = sessionId
|
||||
try {
|
||||
this.buffer.own(ctx.sessionProjections.onChanged((session, key, value, seq) => {
|
||||
this.buffer.own(ctx.sessionProjections.onEventChanged((session, changes, seq) => {
|
||||
if (session.id !== target) return
|
||||
this.buffer.capture(() => {
|
||||
input.append({
|
||||
session, change: { type: 'projection', key, value: value as JsonValue, seq } satisfies SessionProjectionChange,
|
||||
})
|
||||
// Every projection definition validates its value before change publication.
|
||||
const change: SessionProjectionChange = { type: 'projection', seq, changes: changes as SessionProjectionChange['changes'] }
|
||||
input.append({ session, change })
|
||||
})
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
@@ -156,8 +155,8 @@ export class ProjectionListener implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Consume sparse per-key projection replacements.
|
||||
* @returns validated replacement frames; projection seqs need not be contiguous. */
|
||||
/** Consume per-event projection changes.
|
||||
* @returns validated change frames, one per committed event; projection seqs need not be contiguous. */
|
||||
async *changes(): AsyncIterable<SessionProjectionChange> {
|
||||
try {
|
||||
for await (const { change } of this.buffer.items()) {
|
||||
|
||||
@@ -553,10 +553,10 @@ export class SessionController extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to sparse Session projection changes.
|
||||
* Subscribe to Session projection changes.
|
||||
* @param request - target Session.
|
||||
* @param signal - subscription cancellation.
|
||||
* @returns readiness and sparse projection changes.
|
||||
* @returns readiness followed by one projection change frame per committed event.
|
||||
*/
|
||||
@Remote({ mode: 'stream' })
|
||||
observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable<SessionProjectionFrame> {
|
||||
|
||||
@@ -569,12 +569,21 @@ export interface SessionControlBaseline {
|
||||
readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>
|
||||
}
|
||||
|
||||
/** One finished projection value and its durable watermark. */
|
||||
/**
|
||||
* Projection changes caused by one committed Session event; the Host emits one
|
||||
* update per event that changed at least one client-visible projection view.
|
||||
*/
|
||||
export interface SessionProjectionUpdate {
|
||||
readonly sessionId: SessionId
|
||||
readonly key: string
|
||||
readonly value: JsonValue
|
||||
/** Seq of the committed event that caused every listed change. */
|
||||
readonly seq: number
|
||||
/**
|
||||
* One entry per client-visible key whose view the event changed, in
|
||||
* registration order without duplicate keys, with the key's new value as
|
||||
* `val`. A key absent from `changes` is unchanged; unlike a baseline, absence
|
||||
* does not mean an absent capability.
|
||||
*/
|
||||
readonly changes: readonly { readonly key: string; readonly val: SessionProjectionValue }[]
|
||||
}
|
||||
|
||||
/** Host-wide live state stream. Each generation starts with exactly one baseline. */
|
||||
@@ -652,10 +661,10 @@ export type SessionEventFrame =
|
||||
| { readonly type: 'ready' }
|
||||
| SessionEventEntry
|
||||
|
||||
/** One sparse per-key replacement; different keys can share a seq. */
|
||||
/** Projection changes caused by one committed event of the observed Session. */
|
||||
export type SessionProjectionChange = { readonly type: 'projection' } & Omit<SessionProjectionUpdate, 'sessionId'>
|
||||
|
||||
/** Subscription readiness without a baseline, followed by per-key changes. */
|
||||
/** Subscription readiness without a baseline, followed by one change frame per committed event. */
|
||||
export type SessionProjectionFrame =
|
||||
| { readonly type: 'ready' }
|
||||
| SessionProjectionChange
|
||||
|
||||
@@ -52,10 +52,15 @@ describe('Session control Inbox projection', () => {
|
||||
for (;;) {
|
||||
const next = await iterator.next()
|
||||
if (next.done) throw new Error('stream ended before an Inbox value')
|
||||
if (next.value.type === 'projection' && next.value.key === 'inbox') return next.value
|
||||
if (next.value.type === 'projection' && next.value.changes.some(change => change.key === 'inbox')) return next.value
|
||||
}
|
||||
}
|
||||
|
||||
/** The Inbox value one projection frame carries. */
|
||||
function inboxValue(frame: Extract<SessionControlFrame, { type: 'projection' }>): unknown {
|
||||
return frame.changes.find(change => change.key === 'inbox')?.val
|
||||
}
|
||||
|
||||
it('projects both pending lists in baselines and live replacement frames', async () => {
|
||||
const { control, inbox } = await harness()
|
||||
const queued = message('queued')
|
||||
@@ -82,10 +87,10 @@ describe('Session control Inbox projection', () => {
|
||||
const replacement = message('replacement')
|
||||
inbox.append('next-turn', replacement)
|
||||
const replaced = await nextInboxFrame(iterator)
|
||||
expect(replaced.value).toMatchObject({ 'next-turn': [queued, replacement] })
|
||||
expect(inboxValue(replaced)).toMatchObject({ 'next-turn': [queued, replacement] })
|
||||
inbox.remove(steering.id)
|
||||
const removed = await nextInboxFrame(iterator)
|
||||
expect(removed.value).toMatchObject({ 'next-step': [context] })
|
||||
expect(inboxValue(removed)).toMatchObject({ 'next-step': [context] })
|
||||
|
||||
abort.abort()
|
||||
await iterator.next()
|
||||
@@ -109,8 +114,7 @@ describe('Session control Inbox projection', () => {
|
||||
await expect(iterator.next()).resolves.toMatchObject({
|
||||
value: {
|
||||
type: 'projection',
|
||||
key: 'inbox',
|
||||
value: { 'next-turn': [{ id: pending.id }], 'next-step': [] },
|
||||
changes: [{ key: 'inbox', val: { 'next-turn': [{ id: pending.id }], 'next-step': [] } }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -167,9 +171,9 @@ describe('Session control Inbox projection', () => {
|
||||
target: 'next-turn', start: 0, inserted: [pending],
|
||||
})
|
||||
expect(ctx.agents.get(session.id)).toBeUndefined()
|
||||
await expect(nextInboxFrame(iterator)).resolves.toMatchObject({
|
||||
sessionId: session.id, value: { 'next-turn': [pending], 'next-step': [] },
|
||||
})
|
||||
const frame = await nextInboxFrame(iterator)
|
||||
expect(frame.sessionId).toBe(session.id)
|
||||
expect(inboxValue(frame)).toMatchObject({ 'next-turn': [pending], 'next-step': [] })
|
||||
abort.abort()
|
||||
await iterator.next()
|
||||
})
|
||||
@@ -197,14 +201,14 @@ describe('Session control Inbox projection', () => {
|
||||
inbox.append('next-turn', first)
|
||||
inbox.append('next-turn', second)
|
||||
|
||||
const values: InboxState[] = []
|
||||
const values: unknown[] = []
|
||||
ownedContexts.delete(ctx)
|
||||
await ctx.fiber.dispose()
|
||||
for (;;) {
|
||||
const next = await iterator.next()
|
||||
if (next.done) break
|
||||
if (next.value.type === 'projection' && next.value.key === 'inbox') values.push(next.value.value as unknown as InboxState)
|
||||
if (next.value.type === 'projection' && next.value.changes.some(change => change.key === 'inbox')) values.push(inboxValue(next.value))
|
||||
}
|
||||
expect(values.map(value => value['next-turn'].map(item => item.id))).toEqual([[first.id], [first.id, second.id]])
|
||||
expect(values).toMatchObject([{ 'next-turn': [{ id: first.id }] }, { 'next-turn': [{ id: first.id }, { id: second.id }] }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,19 +40,22 @@ describe('HistoryController passive reads', () => {
|
||||
expect(await stream.next()).toMatchObject({ done: true })
|
||||
})
|
||||
|
||||
it('subscribes to sparse projection changes without an Observation', async () => {
|
||||
it('subscribes to per-event projection changes without an Observation', async () => {
|
||||
const f = await fixture()
|
||||
const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession')
|
||||
let receive: Parameters<typeof f.ctx.sessionProjections.onChanged>[0] | undefined
|
||||
let receive: Parameters<typeof f.ctx.sessionProjections.onEventChanged>[0] | undefined
|
||||
const dispose = vi.fn()
|
||||
vi.spyOn(f.ctx.sessionProjections, 'onChanged').mockImplementation((callback) => {
|
||||
vi.spyOn(f.ctx.sessionProjections, 'onEventChanged').mockImplementation((callback) => {
|
||||
receive = callback
|
||||
return dispose
|
||||
})
|
||||
const stream = f.history.observeProjections({ sessionId: f.session.id }, f.abort.signal)[Symbol.asyncIterator]()
|
||||
expect(await stream.next()).toMatchObject({ value: { type: 'ready' } })
|
||||
receive?.(f.session, 'title', 'new title', SessionSeq(0))
|
||||
expect(await stream.next()).toMatchObject({ value: { type: 'projection', key: 'title', value: 'new title', seq: 0 } })
|
||||
receive?.(f.session, [{ key: 'title', val: 'new title' }, { key: 'goal', val: null }], SessionSeq(0))
|
||||
expect(await stream.next()).toEqual({
|
||||
done: false,
|
||||
value: { type: 'projection', seq: 0, changes: [{ key: 'title', val: 'new title' }, { key: 'goal', val: null }] },
|
||||
})
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
f.abort.abort()
|
||||
expect(await stream.next()).toMatchObject({ done: true })
|
||||
|
||||
@@ -34,9 +34,8 @@ function inboxFrame(value: InboxState): Extract<SessionControlFrame, { type: 'pr
|
||||
return {
|
||||
type: 'projection',
|
||||
sessionId: SID,
|
||||
key: 'inbox',
|
||||
changes: [{ key: 'inbox', val: value as never }],
|
||||
seq: nextSeq++,
|
||||
value: value as never,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,17 +178,20 @@ describe('EventListener', () => {
|
||||
})
|
||||
|
||||
describe('ProjectionListener', () => {
|
||||
it('emits sparse changes only for the selected Session', async () => {
|
||||
it('emits one change per event only for the selected Session', async () => {
|
||||
const { ctx, session, abort } = await fixture()
|
||||
let receive!: Parameters<typeof ctx.sessionProjections.onChanged>[0]
|
||||
let receive!: Parameters<typeof ctx.sessionProjections.onEventChanged>[0]
|
||||
const dispose = vi.fn()
|
||||
vi.spyOn(ctx.sessionProjections, 'onChanged').mockImplementation((listener) => { receive = listener; return dispose })
|
||||
vi.spyOn(ctx.sessionProjections, 'onEventChanged').mockImplementation((listener) => { receive = listener; return dispose })
|
||||
using listener = new ProjectionListener(ctx, session.id, abort.signal)
|
||||
const other = ctx.sessions.create(SessionId('other'))
|
||||
receive(other, 'subagent', null, SessionSeq(0))
|
||||
receive(session, 'subagent', null, SessionSeq(1))
|
||||
receive(other, [{ key: 'subagent', val: null }], SessionSeq(0))
|
||||
receive(session, [{ key: 'subagent', val: null }, { key: 'title', val: 'renamed' }], SessionSeq(1))
|
||||
const changes = listener.changes()[Symbol.asyncIterator]()
|
||||
expect(await changes.next()).toMatchObject({ value: { type: 'projection', key: 'subagent', value: null, seq: 1 } })
|
||||
expect(await changes.next()).toEqual({
|
||||
done: false,
|
||||
value: { type: 'projection', seq: 1, changes: [{ key: 'subagent', val: null }, { key: 'title', val: 'renamed' }] },
|
||||
})
|
||||
abort.abort()
|
||||
expect(await changes.next()).toMatchObject({ done: true })
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
@@ -197,7 +200,7 @@ describe('ProjectionListener', () => {
|
||||
it('releases its buffer when projection subscription fails', async () => {
|
||||
const { ctx, session, abort } = await fixture()
|
||||
const remove = vi.spyOn(abort.signal, 'removeEventListener')
|
||||
vi.spyOn(ctx.sessionProjections, 'onChanged').mockImplementation(() => { throw new Error('projection subscribe failed') })
|
||||
vi.spyOn(ctx.sessionProjections, 'onEventChanged').mockImplementation(() => { throw new Error('projection subscribe failed') })
|
||||
expect(() => new ProjectionListener(ctx, session.id, abort.signal)).toThrow('projection subscribe failed')
|
||||
expect(remove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -378,9 +378,9 @@ describe('SessionManager instances', () => {
|
||||
const stopObserving = manager.subscribe(() => { published.resolve(undefined) })
|
||||
try {
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1,
|
||||
key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 })
|
||||
changes: [{ key: 'sessionListMetadata', val: { blank: false, lastPromptAt: 1200 } }], seq: 8 })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S2,
|
||||
key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 })
|
||||
changes: [{ key: 'sessionListMetadata', val: { blank: true, lastPromptAt: null } }], seq: 2 })
|
||||
// The initial notification must finish while neither Session is resident.
|
||||
await published.promise
|
||||
|
||||
@@ -427,7 +427,7 @@ describe('SessionManager query lifetime', () => {
|
||||
const entries = [{ id: S2, createdAt: 1, mode: 'one-shot' as const }]
|
||||
try {
|
||||
if (warm) {
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, changes: [{ key: 'subagentCatalog', val: entries }], seq: 0 })
|
||||
}
|
||||
remote.session.projections.mockRejectedValueOnce(failure)
|
||||
await manager.refreshProjections(S1)
|
||||
@@ -446,9 +446,9 @@ describe('list lifecycle', () => {
|
||||
const manager = makeManager(mock, remote)
|
||||
try {
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1,
|
||||
key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 })
|
||||
changes: [{ key: 'sessionListMetadata', val: { blank: false, lastPromptAt: 1200 } }], seq: 8 })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S2,
|
||||
key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 })
|
||||
changes: [{ key: 'sessionListMetadata', val: { blank: true, lastPromptAt: null } }], seq: 2 })
|
||||
remote.session.list.mockResolvedValue(ok({ items: [
|
||||
summary(S1, { blank: true }), summary(S2),
|
||||
] as never[] }))
|
||||
@@ -457,7 +457,7 @@ describe('list lifecycle', () => {
|
||||
expect(manager.get(S1).getSnapshot().blank).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().blank).toBe(false)
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1,
|
||||
key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 7 })
|
||||
changes: [{ key: 'sessionListMetadata', val: { blank: true, lastPromptAt: null } }], seq: 7 })
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 })
|
||||
} finally {
|
||||
await manager.dispose()
|
||||
@@ -614,7 +614,7 @@ describe('list lifecycle', () => {
|
||||
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async ({ mock, remote }) => {
|
||||
const manager = makeManager(mock, remote)
|
||||
const titleFrame = (title: string, seq: number) => {
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, changes: [{ key: 'title', val: title }], seq })
|
||||
}
|
||||
titleFrame('Newest', 4)
|
||||
titleFrame('Stale', 3)
|
||||
@@ -634,11 +634,34 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies every key of one per-event frame with one list invalidation and per-key seq gating', async ({ mock, remote }) => {
|
||||
remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { blank: true })] as never[] }))
|
||||
const manager = makeManager(mock, remote)
|
||||
onTestFinished(() => manager.dispose())
|
||||
await manager.refreshList()
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, changes: [{ key: 'title', val: 'Newest' }], seq: 4 })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
// Bracket access reaches the private list notifier; projection-store notifiers stay out of the count.
|
||||
const markDirty = vi.spyOn(manager['notifier'], 'markDirty')
|
||||
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S1, seq: 3, changes: [
|
||||
{ key: 'title', val: 'Stale' },
|
||||
{ key: 'sessionListMetadata', val: { blank: false, lastPromptAt: 1200 } },
|
||||
],
|
||||
})
|
||||
expect(markDirty).toHaveBeenCalledOnce()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, title: 'Newest', blank: false })
|
||||
expect(manager.projectionValues(S1)).toMatchObject({ sessionListMetadata: { blank: false, lastPromptAt: 1200 } })
|
||||
})
|
||||
|
||||
it('fills cold titles from the list rows\' projections block as cached values', async ({ mock, remote }) => {
|
||||
const manager = makeManager(mock, remote)
|
||||
// A push frame landed before the list: S2's title is a sequenced value.
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9,
|
||||
type: 'projection', sessionId: S2, changes: [{ key: 'title', val: 'Pushed' }], seq: 9,
|
||||
})
|
||||
remote.session.list.mockResolvedValue(ok({
|
||||
items: [
|
||||
@@ -660,7 +683,7 @@ describe('list lifecycle', () => {
|
||||
const manager = makeManager(mock, remote)
|
||||
try {
|
||||
await manager.refreshList()
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, changes: [{ key: 'title', val: 'Unflushed' }], seq: 4 })
|
||||
|
||||
manager.handleConnected()
|
||||
await manager.refreshList()
|
||||
@@ -738,7 +761,7 @@ describe('subagent catalogs', () => {
|
||||
const manager = makeManager(mock, remote)
|
||||
onTestFinished(() => manager.dispose())
|
||||
const entries = [{ id: S2, createdAt: 1, mode: 'external' as const, label: 'external task' }]
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, changes: [{ key: 'subagentCatalog', val: entries }], seq: 0 })
|
||||
|
||||
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual(entries)
|
||||
expect(manager.subagentAddress(S2)).toBeUndefined()
|
||||
@@ -750,8 +773,9 @@ describe('subagent catalogs', () => {
|
||||
onTestFinished(() => manager.dispose())
|
||||
manager.handleSessionAdded(summary(S2, { parentSessionId: S1, origin: 'subagent' }))
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0,
|
||||
value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }],
|
||||
type: 'projection', sessionId: S1, seq: 0, changes: [
|
||||
{ key: 'subagentCatalog', val: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(manager.subagentAddress(S2)).toEqual({
|
||||
@@ -903,14 +927,17 @@ describe('subagent catalogs', () => {
|
||||
const client = await start()
|
||||
const manager = new SessionManager(client.ctx.remote)
|
||||
const entries = [{ id: S2, createdAt: 1, mode: 'unknown' as const }]
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, changes: [{ key: 'subagentCatalog', val: entries }], seq: 0 })
|
||||
const address = manager.subagentAddress(S2)!
|
||||
expect(address).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unknown' })
|
||||
manager.resolveTarget(address)
|
||||
const child = manager.get(S2)
|
||||
expect(child.getSnapshot().subagent?.address.mode).toBe('unknown')
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'subagent', seq: 0,
|
||||
value: { seq: SessionSeq(0), mode: 'continuable', label: 'repaired child' } })
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S2, seq: 0, changes: [
|
||||
{ key: 'subagent', val: { seq: SessionSeq(0), mode: 'continuable', label: 'repaired child' } },
|
||||
],
|
||||
})
|
||||
await child.open()
|
||||
expect(child.getSnapshot().subagent?.address).toEqual({ ...address, mode: 'continuable' })
|
||||
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual(entries)
|
||||
@@ -921,8 +948,9 @@ describe('subagent catalogs', () => {
|
||||
const manager = makeManager(mock, remote)
|
||||
manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true }))
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 4,
|
||||
value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }],
|
||||
type: 'projection', sessionId: S1, seq: 4, changes: [
|
||||
{ key: 'subagentCatalog', val: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }] },
|
||||
],
|
||||
})
|
||||
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual([
|
||||
{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' },
|
||||
@@ -1005,8 +1033,9 @@ describe('subagent catalogs', () => {
|
||||
const manager = makeManager(mock, remote)
|
||||
const read = manager.refreshProjections(S1)
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 5,
|
||||
value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'pushed' }],
|
||||
type: 'projection', sessionId: S1, seq: 5, changes: [
|
||||
{ key: 'subagentCatalog', val: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'pushed' }] },
|
||||
],
|
||||
})
|
||||
response.resolve(ok({ kind: 'sequenced', asOfSeq: 3, values: { subagentCatalog: [] } }))
|
||||
await read
|
||||
@@ -1034,8 +1063,11 @@ describe('subagent catalogs', () => {
|
||||
remote.session.projections.mockImplementation(() => response.promise)
|
||||
const manager = makeManager(mock, remote)
|
||||
const read = manager.refreshProjections(S1)
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 1,
|
||||
value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] })
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S1, seq: 1, changes: [
|
||||
{ key: 'subagentCatalog', val: [{ id: S2, createdAt: 1, mode: 'one-shot' }] },
|
||||
],
|
||||
})
|
||||
response.resolve(ok(null))
|
||||
await read
|
||||
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual([
|
||||
@@ -1084,9 +1116,12 @@ describe('subagent catalogs', () => {
|
||||
it('keeps completed unselected child metadata with its durable catalog row', ({ mock, remote }) => {
|
||||
const manager = makeManager(mock, remote)
|
||||
manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true }))
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'title', value: 'finished child', seq: 2 })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 1,
|
||||
value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S2, changes: [{ key: 'title', val: 'finished child' }], seq: 2 })
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S1, seq: 1, changes: [
|
||||
{ key: 'subagentCatalog', val: [{ id: S2, createdAt: 1, mode: 'one-shot' }] },
|
||||
],
|
||||
})
|
||||
manager.handleSessionRemoved(S2)
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'finished child', running: false })
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.running).toBe(false)
|
||||
@@ -1101,8 +1136,11 @@ describe('subagent catalogs', () => {
|
||||
it('retries a failed initial read without discarding pushed entries', async ({ mock, remote }) => {
|
||||
remote.session.projections.mockImplementation(() => Promise.resolve(err(new RemoteError('gateway/internal', 'offline', {}))))
|
||||
const manager = makeManager(mock, remote)
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 2,
|
||||
value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] })
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S1, seq: 2, changes: [
|
||||
{ key: 'subagentCatalog', val: [{ id: S2, createdAt: 1, mode: 'one-shot' }] },
|
||||
],
|
||||
})
|
||||
await manager.refreshProjections(S1)
|
||||
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'error', values: { subagentCatalog: [{ id: S2 }] } })
|
||||
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 2, values: { subagentCatalog: [] } })))
|
||||
|
||||
@@ -228,13 +228,13 @@ describe('manager frame routing', () => {
|
||||
it('lands projection frames before instantiation and the Session adopts the same store', ({ mock, remote }) => {
|
||||
const manager = makeManager(mock, remote)
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
|
||||
type: 'projection', sessionId: sid('s1'), changes: [{ key: 'test/marks', val: { marks: ['early'] } }], seq: 7,
|
||||
})
|
||||
const session = manager.get(sid('s1'))
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
|
||||
// Frames after instantiation land in the same store.
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9,
|
||||
type: 'projection', sessionId: sid('s1'), changes: [{ key: 'test/marks', val: { marks: ['later'] } }], seq: 9,
|
||||
})
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
|
||||
})
|
||||
@@ -246,7 +246,7 @@ describe('manager frame routing', () => {
|
||||
}))
|
||||
await manager.refreshList()
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4,
|
||||
type: 'projection', sessionId: sid('s1'), changes: [{ key: 'title', val: 'Projected title' }], seq: 4,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
|
||||
@@ -318,7 +318,7 @@ describe('manager frame routing', () => {
|
||||
|
||||
// A frame past the block's watermark still advances it.
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Frame title', seq: 41,
|
||||
type: 'projection', sessionId: sid('s1'), changes: [{ key: 'title', val: 'Frame title' }], seq: 41,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Frame title')
|
||||
@@ -342,8 +342,8 @@ describe('manager frame routing', () => {
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
|
||||
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: sid('s1'), key: 'test/marks',
|
||||
value: { marks: ['live'] }, seq: 3,
|
||||
type: 'projection', sessionId: sid('s1'), changes: [{ key: 'test/marks', val: { marks: ['live'] } }],
|
||||
seq: 3,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues)
|
||||
@@ -358,10 +358,12 @@ describe('manager frame routing', () => {
|
||||
}))
|
||||
await manager.refreshList()
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4,
|
||||
type: 'projection', sessionId: sid('s1'), changes: [{ key: 'title', val: 'Doomed' }], seq: 4,
|
||||
})
|
||||
if (catalog !== undefined) {
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: sid('s1'), key: 'subagentCatalog', value: catalog, seq: 4 })
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: sid('s1'), changes: [{ key: 'subagentCatalog', val: catalog }], seq: 4,
|
||||
})
|
||||
}
|
||||
manager.handleSessionRemoved(sid('s1'))
|
||||
expect(manager.getListSnapshot().projectionsBySession[sid('s1')]).toBeUndefined()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user