dsh-hitl 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-hitl · Human-in-the-loop for any tool
1
+ # dsh-hitl · Human-in-the-loop for DeepSeek Harness
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/dsh-hitl)](https://www.npmjs.com/package/dsh-hitl) [![license](https://img.shields.io/npm/l/dsh-hitl)](LICENSE) [![Website](https://img.shields.io/badge/Website-4c566a)](https://yunpengdon.github.io/dsh-hitl-landing/)
4
4
 
@@ -99,17 +99,19 @@ export function apply(ctx) {
99
99
  }
100
100
  ```
101
101
 
102
- > **Always pass `owner` (the third argument).** Mounts are state owned by the `hitl` plugin, not by yours. Without an owner, the disposer `protect()` returns is the only way to unbind — and "return a function from `apply`" is **not** a lifetime: it was measured that the mount outlives the unloaded plugin row, leaving that tool blocked forever. With `ctx`, `hitl` registers an effect on your plugin's context and the mount is released when your plugin unloads. The equivalent explicit form is to wrap it in your own effect: `ctx.effect(() => ctx.hitl.protect('bash', {...}), 'my-plugin: hitl mount')`. If a mount does linger, look at `ctx.hitl.list()` and clear it with `ctx.hitl.unprotect('bash')`, or toggle the `hitl` row off and on in the plugins page (which rebuilds the plugin's state).
102
+ > **`owner` decides whose account this mount's revocation is filed under.** It is the calling plugin's Cordis context — normally the `ctx` your `apply` received: pass it and `hitl` files the mount's release on the calling plugin's fiber, so the mount is removed automatically when that plugin is unloaded, disabled, or reloaded. The mount itself is state owned by the `hitl` plugin, not by yours — so without an owner the disposer `protect()` returns is the only way to unbind, and "return a function from `apply`" is **not** a lifetime: it was measured that the mount outlives the unloaded plugin row, leaving that tool blocked forever. The equivalent explicit form is to wrap it in your own effect: `ctx.effect(() => ctx.hitl.protect('bash', {...}), 'my-plugin: hitl mount')`. If a mount does linger, look at `ctx.hitl.list()` and clear it with `ctx.hitl.unprotect('bash')`, or toggle the `hitl` row off and on in the plugins page (which rebuilds the plugin's state).
103
103
 
104
104
  ### 2.3 Service API
105
105
 
106
106
  | Member | Purpose |
107
107
  |---|---|
108
- | `protect(matcher, options?, owner?)` | Mount one tool; pass the caller's ctx as `owner` for automatic unbinding; returns a disposer |
108
+ | `protect(matcher, options?, owner?)` | Mount one tool; returns a disposer. Pass the caller's own ctx as `owner` to file this mount's unbinding under that plugin's lifetime: the mount is removed automatically when the caller is unloaded, disabled, or reloaded. Omit it and the mount outlives its caller |
109
109
  | `unprotect(matcher)` | Remove the mounts a matcher describes; returns how many were removed |
110
110
  | `list()` | Every current mount (diagnostics) |
111
111
  | `pending()` | Every request currently waiting for a human (diagnostics) |
112
112
 
113
+ > `owner` is optional in the signature because there is a second entry point: the rows in `config.protect` belong to the `hitl` plugin itself (they are cleared when that row unloads) and need no owner. On the `ctx.hitl.protect()` API, however, it is required in practice — omitting it is not a legal simplification but a trap.
114
+
113
115
  `matcher`: a tool name / a glob string containing `*` / a `RegExp` / an array of those / `(exec) => boolean`.
114
116
  When one tool is mounted several times, **the most recent mount wins** (the config row registers first and plugins later — so a plugin can override the config).
115
117
 
@@ -146,6 +148,7 @@ Without `fields`, the proposal is **every parameter of this call**:
146
148
  | `whenUnavailable` | `reject` \| `wait` | `reject` | Behaviour with no browser connected: `reject` = fail-closed; `wait` = wait indefinitely, so **pair it with a `countdown`** or that call hangs until its Session ends (the host warns at mount time); see §7.1 |
147
149
  | `enabled` | boolean \| `(exec) => boolean` | `true` | Turn a mount off temporarily / make it conditional |
148
150
  | `maxFieldChars` | number | `20000` | Per-field render cap; longer values are truncated with a note |
151
+ | `resolveTimeoutMs` | number | `5000` | Bound on resolving one **computed** field; a timeout or a throw fails that field alone, never the call |
149
152
 
150
153
  ### FieldSpec
151
154
 
@@ -154,6 +157,7 @@ Without `fields`, the proposal is **every parameter of this call**:
154
157
  render: 'markdown', // markdown | text | diff | json | hidden
155
158
  editable: true, // overrides the default editability
156
159
  labels: ['irreversible'], // field-level labels
160
+ value: 'fixed copy', // this field's text: a literal, or (exec) => string | Promise<string>
157
161
  diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // used when render: 'diff'
158
162
  ```
159
163
 
@@ -161,6 +165,31 @@ Without `fields`, the proposal is **every parameter of this call**:
161
165
  - When `fields` is given, **only** the listed fields are shown (parameters you leave out never reach the panel).
162
166
  - Config rows may shorten a spec to a bare string: `fields: ['command', { param: 'cwd', title: 'Working directory' }]`.
163
167
 
168
+ ### Computed fields: putting what the arguments do not carry on the card
169
+
170
+ `diff.before` / `diff.after` / `diff.path` normally name a **parameter** (the host reads `args[name]`). Like a field's `value`, any of them may instead be a **function**: the host calls it with the pending `exec` before the card opens and uses what it returns as the text.
171
+
172
+ ```js
173
+ ctx.hitl.protect('update_index', {
174
+ title: 'Update the knowledge-tree index',
175
+ fields: [
176
+ { param: 'content', title: 'New index text', render: 'markdown' },
177
+ { param: 'index', title: 'What changes in the index file', render: 'diff', editable: false,
178
+ // `before` reads the old index from disk, `after` derives the new file text —
179
+ // neither of them is one of the call's parameters
180
+ diff: { path: 'index.md',
181
+ before: async exec => readIndexFile(exec),
182
+ after: exec => `# Knowledge tree index\n\n${exec.arguments.content}` } },
183
+ ],
184
+ })
185
+ ```
186
+
187
+ - A string `diff.path` that is **not** a parameter name is used as a literal path: the file a call is about to change is often not one of its arguments.
188
+ - Resolvers may be async; one field's resolution is bounded by `resolveTimeoutMs` (5s by default).
189
+ - **A failed resolution never blocks the decision**: a throw or a timeout turns *that field* into one `⚠️ …could not be resolved…` line, the card still opens, and the human still decides. If the resolution machinery itself breaks, the proposal degrades to the raw arguments instead of denying the call.
190
+ - The wire format and the browser half are unchanged: both sides of a diff always travelled in the frame; the panel just renders them.
191
+ - Each side of a diff is cut past `maxDiffLines` (2000 lines) with a note, so a whole-file diff cannot become an unbounded frame.
192
+
164
193
  ### The three endings of a countdown
165
194
 
166
195
  `countdown.action` decides what the host does when the countdown reaches zero:
@@ -385,28 +414,28 @@ Reply: `{ ok: true, accepted: true, held?, expiresAt? }` or
385
414
 
386
415
  ```text
387
416
  dsh-hitl/
388
- ├── index.js 639 host half: service + gate + countdown/hold + SSE & decision routes + token injection
417
+ ├── index.js 666 host half: service + gate + countdown/hold + SSE & decision routes + token injection
389
418
  ├── client.js 1918 browser half: decision panel + frame-level notice (self-contained classic script, no imports)
390
419
  ├── lib/ the host half's pure logic: no Cordis, no DOM, testable on its own
391
- │ ├── protocol.js 182 frame and uplink validation, limits, error codes (the single source of the protocol)
392
- │ ├── resolve.js 311 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
393
- │ ├── fields.js 244 default proposal derivation, diff pairing, decision → model-visible text
420
+ │ ├── protocol.js 195 frame and uplink validation, limits, error codes (the single source of the protocol)
421
+ │ ├── resolve.js 363 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
422
+ │ ├── fields.js 449 default proposal derivation, diff pairing, computed-field resolution, decision → model-visible text
394
423
  │ └── pending.js 217 the pending-decision state machine (injectable clock): countdown, hold, settle-once
395
424
  ├── locale/ the plugin list's name and description (shape must be {"meta":{...}}, see §6.3)
396
425
  │ ├── en.json 6
397
426
  │ └── zh.json 6
398
427
  ├── docs/ screenshot sources, deliberately NOT shipped: the README links the landing page instead
399
- ├── tests/ zero-dependency Node tests, 153 cases in total
428
+ ├── tests/ zero-dependency Node tests, 167 cases in total
400
429
  │ ├── client.test.js 818 (65) the browser half's pure helpers + store / connection / seat
401
- │ ├── fields.test.js 269 (26) proposal derivation and decision text
430
+ │ ├── fields.test.js 387 (37) proposal derivation, computed fields and decision text
402
431
  │ ├── pending.test.js 240 (18) the state machine (injected clock, no real waiting)
403
- │ ├── resolve.test.js 181 (19) matchers and option normalization
432
+ │ ├── resolve.test.js 224 (22) matchers and option normalization
404
433
  │ ├── protocol.test.js 117 (16) frame and uplink validation
405
434
  │ ├── docs.test.js 121 (5) the two READMEs, the landing-page link, and the numbers in this very table
406
435
  │ └── manifest.test.js 77 (4) package metadata and the locale resource shape
407
436
  ├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC)
408
437
  ├── package.json 68 manifest: exports / dsh.bundle.patch / dsh.client / icon
409
- ├── cordis.patch.yml 17 the bundle's configuration layer (inserts the row with id `hitl`)
438
+ ├── cordis.patch.yml 18 the bundle's configuration layer (inserts the row with id `hitl`)
410
439
  ├── icon.svg 6 the plugin list icon
411
440
  ├── LICENSE MIT
412
441
  ├── README.md this document
@@ -436,7 +465,7 @@ dsh-hitl/
436
465
 
437
466
  ```sh
438
467
  node --check index.js client.js lib/*.js # syntax
439
- npm test # 153 zero-dependency cases: pure functions, state machines, the channel
468
+ npm test # 167 zero-dependency cases: pure functions, state machines, the channel
440
469
  npm run check # both of the above (syntax + the whole suite)
441
470
  ```
442
471
 
package/README.zh.md CHANGED
@@ -105,10 +105,12 @@ export function apply(ctx) {
105
105
  }
106
106
  ```
107
107
 
108
- > **一定要带 `owner`(第三个参数)**:挂载是记在 `hitl` 插件里的状态,不是你的插件状态。
109
- > 不传 owner 时,`protect()` 返回的 disposer 就是唯一的解绑手段——而"从 `apply` 里 return 一个函数"
108
+ > **`owner` 决定这次挂载的"撤销权"记在谁账上。** 它是调用方插件的 Cordis 上下文,通常就是你
109
+ > `apply` 收到的 `ctx`:传了它,`hitl` 会把这次挂载的解绑登记在调用方插件的 fiber 上,
110
+ > 该插件被卸载 / 禁用 / 重载时,挂载自动移除。
111
+ > 挂载本身是记在 `hitl` 插件里的状态,不是你的插件状态——所以不传 owner 时,`protect()` 返回的
112
+ > disposer 就是唯一的解绑手段,而"从 `apply` 里 return 一个函数"
110
113
  > **不算生命周期**:实测过,插件行被卸载后挂载仍然生效,那个工具会被永久拦下去。
111
- > 传了 `ctx` 之后,`hitl` 会在你的插件上下文上注册一个 effect,插件卸载即自动解绑。
112
114
  > 另一种等价写法是把它包进你自己的 effect:
113
115
  > `ctx.effect(() => ctx.hitl.protect('bash', {...}), 'my-plugin: hitl mount')`。
114
116
  > 万一留下了残留挂载,用 `ctx.hitl.list()` 看一眼,`ctx.hitl.unprotect('bash')` 清掉;
@@ -118,11 +120,15 @@ export function apply(ctx) {
118
120
 
119
121
  | 成员 | 说明 |
120
122
  |---|---|
121
- | `protect(matcher, options?, owner?)` | 挂载一个工具;`owner` 传调用方 ctx 即可自动解绑;返回 disposer |
123
+ | `protect(matcher, options?, owner?)` | 挂载一个工具,返回 disposer。`owner` 需传入调用方自己的 ctx,用于把这次挂载的解绑登记到调用方插件的生命周期上:调用方被卸载 / 禁用 / 重载时自动移除;不传则挂载会活过调用方 |
122
124
  | `unprotect(matcher)` | 按 matcher 描述移除挂载,返回移除数量 |
123
125
  | `list()` | 当前所有挂载(诊断用) |
124
126
  | `pending()` | 当前所有等待人类决策的请求(诊断用) |
125
127
 
128
+ > 签名上 `owner` 是可选的,因为还有另一条入口:`config.protect` 那些行天然归 `hitl` 自己管
129
+ > (它们随 `hitl` 这一行卸载而清空),不需要 owner。但用 `ctx.hitl.protect()` 这个 API 时,
130
+ > 它就是必需的——不传不是"一种合法的简化",而是一个坑。
131
+
126
132
  `matcher`:工具名 / 含 `*` 的通配串 / `RegExp` / 上面几者的数组 / `(exec) => boolean`。
127
133
  同一个工具被挂载多次时,**后挂载的覆盖先挂载的**(配置行先注册,插件后注册,所以插件可以覆盖配置)。
128
134
 
@@ -159,6 +165,7 @@ export function apply(ctx) {
159
165
  | `whenUnavailable` | `reject` \| `wait` | `reject` | 没有任何浏览器连接时的行为:`reject` = fail-closed;`wait` = 一直等,**务必配 `countdown`**,否则那次调用会挂到会话结束(挂载时宿主会给 `dsh-hitl:` 警告),见 §7.1 |
160
166
  | `enabled` | boolean \| `(exec) => boolean` | `true` | 临时关闭 / 条件挂载 |
161
167
  | `maxFieldChars` | number | `20000` | 单字段渲染上限,超出会截断并提示 |
168
+ | `resolveTimeoutMs` | number | `5000` | 单个**计算字段**的解析上限;超时或抛错只让那个字段显示失败,不阻断这次调用 |
162
169
 
163
170
  ### FieldSpec
164
171
 
@@ -167,6 +174,7 @@ export function apply(ctx) {
167
174
  render: 'markdown', // markdown | text | diff | json | hidden
168
175
  editable: true, // 覆盖默认可编辑性
169
176
  labels: ['不可撤销'], // 字段级标签
177
+ value: '固定文案', // 该字段的文本:字面量,或 (exec) => string | Promise<string>
170
178
  diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // render: 'diff' 时用
171
179
  ```
172
180
 
@@ -174,6 +182,30 @@ export function apply(ctx) {
174
182
  - `fields` 给定时**只显示**列出的字段(未列出的参数不会出现在面板里)。
175
183
  - 配置行里可以简写成字符串:`fields: ['command', { param: 'cwd', title: '工作目录' }]`。
176
184
 
185
+ ### 计算字段:把参数之外的东西放进卡里
186
+
187
+ `diff.before` / `diff.after` / `diff.path` 平时填的是**参数名**(宿主取 `args[名字]`)。它们与字段的 `value` 一样,也可以填**函数**:宿主在弹卡前用这次调用的 `exec` 调它,把返回值当作文本。
188
+
189
+ ```js
190
+ ctx.hitl.protect('update_index', {
191
+ title: '更新知识树索引',
192
+ fields: [
193
+ { param: 'content', title: '新索引正文', render: 'markdown' },
194
+ { param: 'index', title: '索引文件的变化', render: 'diff', editable: false,
195
+ // before 读磁盘上的旧索引,after 由新正文推出来——两者都不在参数里
196
+ diff: { path: 'index.md',
197
+ before: async exec => readIndexFile(exec),
198
+ after: exec => `# 知识树索引\n\n${exec.arguments.content}` } },
199
+ ],
200
+ })
201
+ ```
202
+
203
+ - 字符串形式的 `diff.path` 若**不是**某个参数名,就当作字面路径使用:被改的文件常常不是这次调用的参数。
204
+ - 函数可以是 async;每个字段的解析上限是 `resolveTimeoutMs`(默认 5s)。
205
+ - **解析失败不阻断**:函数抛错或超时,只会把**那个字段**变成一行 `⚠️ 无法解析该字段:…`,卡照常弹出、人照常决策。整个解析过程坏掉时,提案退回到"参数原样",而不是拒绝调用。
206
+ - wire 格式与浏览器半都没有变化:diff 的两侧本来就在帧里,前端只是照常渲染。
207
+ - diff 的每一侧超过 `maxDiffLines`(2000 行)会被截断并标注,避免整文件 diff 变成无上限的一帧。
208
+
177
209
  ### 倒计时的三种结局
178
210
 
179
211
  `countdown.action` 决定倒计时归零后宿主怎么判:
@@ -448,28 +480,28 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
448
480
 
449
481
  ```text
450
482
  dsh-hitl/
451
- ├── index.js 639 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
483
+ ├── index.js 666 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
452
484
  ├── client.js 1918 浏览器半:决策面板 + 帧级浮层(自包含 classic script,无 import)
453
485
  ├── lib/ 宿主半的纯逻辑:不碰 Cordis、不碰 DOM,可单独跑测试
454
- │ ├── protocol.js 182 帧与上行校验、上限、错误码(协议规范的唯一出处)
455
- │ ├── resolve.js 311 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
456
- │ ├── fields.js 244 默认提案推导、diff 配对、决策 → 模型可见文本
486
+ │ ├── protocol.js 195 帧与上行校验、上限、错误码(协议规范的唯一出处)
487
+ │ ├── resolve.js 363 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
488
+ │ ├── fields.js 449 默认提案推导、diff 配对、计算字段解析、决策 → 模型可见文本
457
489
  │ └── pending.js 217 待决策状态机(可注入时钟):倒计时、持握、恰好一次的结算
458
490
  ├── locale/ 插件列表里的名字与简介(形状必须是 {"meta":{...}},见 §6.3)
459
491
  │ ├── en.json 6
460
492
  │ └── zh.json 6
461
493
  ├── docs/ 截图源文件,**故意不进 npm 包**:README 改为链接 landing page
462
- ├── tests/ 零依赖 Node 测试,共 153 个用例
494
+ ├── tests/ 零依赖 Node 测试,共 167 个用例
463
495
  │ ├── client.test.js 818 (65)浏览器半的纯函数 + store / connection / seat
464
- │ ├── fields.test.js 269 (26)提案推导与决策文本
496
+ │ ├── fields.test.js 387 (37)提案推导、计算字段与决策文本
465
497
  │ ├── pending.test.js 240 (18)状态机(注入时钟,无真实等待)
466
- │ ├── resolve.test.js 181 (19)matcher 与选项归一化
498
+ │ ├── resolve.test.js 224 (22)matcher 与选项归一化
467
499
  │ ├── protocol.test.js 117 (16)帧与上行校验
468
500
  │ ├── docs.test.js 121 (5)两份 README、landing page 链接,以及上面这张表里的每个数字
469
501
  │ └── manifest.test.js 77 (4)包元数据与 locale 资源形状
470
502
  ├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC)
471
503
  ├── package.json 68 清单:exports / dsh.bundle.patch / dsh.client / icon
472
- ├── cordis.patch.yml 17 bundle 的配置层(插入 id 为 hitl 的那一行)
504
+ ├── cordis.patch.yml 18 bundle 的配置层(插入 id 为 hitl 的那一行)
473
505
  ├── icon.svg 6 插件列表图标
474
506
  ├── LICENSE MIT
475
507
  ├── README.md 本文档(英文版)
@@ -505,7 +537,7 @@ dsh-hitl/
505
537
 
506
538
  ```sh
507
539
  node --check index.js client.js lib/*.js # 语法
508
- npm test # 153 个纯函数/状态机/通道用例,零依赖
540
+ npm test # 167 个纯函数/状态机/通道用例,零依赖
509
541
  npm run check # 上面两步合起来(语法 + 全部用例)
510
542
  ```
511
543
 
package/cordis.patch.yml CHANGED
@@ -2,10 +2,11 @@
2
2
  #
3
3
  # The row's `config.protect` list mounts HITL on tools without writing code:
4
4
  # each entry is { tool, ...hitlOptions } and mirrors ctx.hitl.protect(tool, options).
5
- # Plugins mount through the `hitl` service instead:
5
+ # Plugins mount through the `hitl` service instead — note the third argument:
6
+ # the callback's own ctx, so the mount is released when your plugin unloads.
6
7
  #
7
8
  # ctx.inject(['hitl'], (ctx) => {
8
- # ctx.hitl.protect('bash', { countdown: { seconds: 30, action: 'reject' } })
9
+ # ctx.hitl.protect('bash', { countdown: { seconds: 30, action: 'reject' } }, ctx)
9
10
  # })
10
11
  #
11
12
  # A patch replaces the whole `config` of a row it overrides, so keep every key
package/index.js CHANGED
@@ -21,7 +21,9 @@ import {
21
21
  DEFAULT_ENDPOINT, ERROR_CODES, GLOBAL_KEY, LIMITS, OUTCOMES, errorBody, frameHoldAck,
22
22
  framePing, frameRequest, frameSettled, frameSnapshot, okBody, parseUplink, sseChunk,
23
23
  } from './lib/protocol.js'
24
- import { buildRequest, capReason, describeDecision, isRevision, revisionContext } from './lib/fields.js'
24
+ import {
25
+ buildRequest, capReason, describeDecision, hasComputed, isRevision, resolveFields, revisionContext,
26
+ } from './lib/fields.js'
25
27
  import { DEFAULT_HOLD_GRACE_MS, createPendingRegistry } from './lib/pending.js'
26
28
  import { matchMount, mountApplies, normalizeMount, normalizeProtectList } from './lib/resolve.js'
27
29
 
@@ -234,6 +236,8 @@ export function apply(ctx, config = {}) {
234
236
  rejectFeedback: mount.options.reject.feedback,
235
237
  modify: mount.options.modify.mode,
236
238
  whenUnavailable: mount.options.whenUnavailable,
239
+ /** Whether this mount's proposal needs the host to compute a field. */
240
+ computed: hasComputed(mount.options),
237
241
  })),
238
242
  /** Every decision still waiting for a human, oldest first. */
239
243
  pending: () => registry.list().map(request => ({
@@ -315,6 +319,23 @@ export function apply(ctx, config = {}) {
315
319
  }
316
320
  }
317
321
 
322
+ /**
323
+ * Collect a mount's computed fields for one call.
324
+ *
325
+ * If the resolution itself breaks, the proposal degrades to the call's own
326
+ * arguments: a panel of raw parameters is still a decision the human can make,
327
+ * while a denied call is not. A resolver that fails on its own degrades inside
328
+ * `resolveFields` instead, into one field that says so.
329
+ */
330
+ async function resolutionFor(exec, mount) {
331
+ try {
332
+ return await resolveFields(exec, mount, { timeoutMs: mount.options.resolveTimeoutMs, warn })
333
+ } catch (error) {
334
+ warn(`the computed fields of tool ${JSON.stringify(exec.name)} could not be resolved: ${String(error)}`)
335
+ return undefined
336
+ }
337
+ }
338
+
318
339
  async function gate(exec, next) {
319
340
  let mount
320
341
  try {
@@ -335,7 +356,13 @@ export function apply(ctx, config = {}) {
335
356
  }
336
357
  const id = `hitl:${String(exec.callId ?? randomUUID())}`
337
358
  try {
338
- const request = buildRequest({ execution: exec, mount, sessionId, id })
359
+ const request = buildRequest({
360
+ execution: exec,
361
+ mount,
362
+ sessionId,
363
+ id,
364
+ resolution: await resolutionFor(exec, mount),
365
+ })
339
366
  const settlement = await waitForDecision(id, request, mount, exec)
340
367
  return toPreToolDecision(exec, request, mount, settlement)
341
368
  } catch (error) {
package/lib/fields.js CHANGED
@@ -6,6 +6,11 @@
6
6
  * Defaults follow the plugin's core promise: with no configuration at all, the
7
7
  * proposal is the call's own parameters — one field per parameter, titled by the
8
8
  * parameter's key, rendered as an editable markdown box.
9
+ *
10
+ * A mount that must show something the arguments do not carry — the file a call
11
+ * is about to overwrite, the node it is about to delete — declares it as a
12
+ * function; `resolveFields` runs those before the request is built. The browser
13
+ * half stays a pure renderer of the wire shape and needs no change for them.
9
14
  */
10
15
 
11
16
  import { LIMITS, truncate } from './protocol.js'
@@ -14,6 +19,9 @@ import { defaultEditable } from './resolve.js'
14
19
  /** Model-facing prefix every reason this plugin produces starts with. */
15
20
  export const REASON_PREFIX = 'HITL'
16
21
 
22
+ /** Default bound on one computed field, used when the mount sets no `resolveTimeoutMs`. */
23
+ export const RESOLVE_TIMEOUT_MS = LIMITS.resolveTimeoutMs
24
+
17
25
  /** Render a parameter value as proposal text. */
18
26
  export function renderValue(value) {
19
27
  if (typeof value === 'string') return value
@@ -36,6 +44,25 @@ export function inferRender() {
36
44
  return 'markdown'
37
45
  }
38
46
 
47
+ /** Whether a value is computed from the pending call instead of read from an argument. */
48
+ export function isResolver(value) {
49
+ return typeof value === 'function'
50
+ }
51
+
52
+ /**
53
+ * The diff option that applies to one field: the field's own, else the mount's.
54
+ *
55
+ * Shared by the renderer and the resolver on purpose: a field must never be
56
+ * resolved as a diff and then rendered as something else, or the other way
57
+ * round.
58
+ */
59
+ function diffOptionOf(spec, mount) {
60
+ const requested = spec.diff ?? mount.options.diff
61
+ return typeof requested === 'object' && requested !== null && !Array.isArray(requested)
62
+ ? requested
63
+ : undefined
64
+ }
65
+
39
66
  function schemaOf(execution) {
40
67
  const parameters = execution.schema?.parameters
41
68
  return typeof parameters === 'object' && parameters !== null ? parameters : undefined
@@ -66,10 +93,12 @@ export function defaultFieldSpecs(execution) {
66
93
  }
67
94
 
68
95
  function diffSpec(spec, mount, execution) {
69
- const requested = spec.diff ?? mount.options.diff
70
- if (requested === undefined || requested === null || typeof requested !== 'object') return undefined
96
+ const requested = diffOptionOf(spec, mount)
97
+ if (requested === undefined) return undefined
71
98
  const before = requested.before
72
99
  const after = requested.after
100
+ // Function sides are computed before the request is built (see
101
+ // `resolveFields`); this path only pairs two arguments.
73
102
  if (typeof before !== 'string' || typeof after !== 'string') return undefined
74
103
  const args = argumentsOf(execution)
75
104
  if (args === undefined) return undefined
@@ -80,16 +109,161 @@ function diffSpec(spec, mount, execution) {
80
109
  }
81
110
  }
82
111
 
112
+ // ── computed fields ─────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Await one resolver with a bound.
116
+ *
117
+ * A resolver is caller-supplied code, so it can hang; the gate must not hang
118
+ * with it. The losing side of the race keeps running — a promise cannot be
119
+ * cancelled — but nothing awaits it, and the timer is unref'd so a pending
120
+ * timeout never holds the process open on its own.
121
+ */
122
+ async function callResolver(resolver, execution, timeoutMs, label) {
123
+ let timer
124
+ try {
125
+ return await Promise.race([
126
+ Promise.resolve().then(() => resolver(execution)),
127
+ new Promise((_resolve, reject) => {
128
+ timer = setTimeout(() => { reject(new Error(`${label} timed out after ${timeoutMs}ms`)) }, timeoutMs)
129
+ if (typeof timer?.unref === 'function') timer.unref()
130
+ }),
131
+ ])
132
+ } finally {
133
+ clearTimeout(timer)
134
+ }
135
+ }
136
+
137
+ /** Render whatever a resolver returned as field text. */
138
+ function renderComputed(value) {
139
+ return value === undefined || value === null ? '' : renderValue(value)
140
+ }
141
+
142
+ /**
143
+ * Read one diff side: a resolver computes it, a string names the argument that
144
+ * carries it. `path` may also be a literal, because the file a call is about to
145
+ * change is often not one of the call's parameters.
146
+ */
147
+ async function readDiffSide(declared, key, execution, timeoutMs) {
148
+ if (isResolver(declared)) {
149
+ return renderComputed(await callResolver(declared, execution, timeoutMs, `diff.${key}`))
150
+ }
151
+ const args = argumentsOf(execution)
152
+ const carried = args === undefined ? undefined : args[declared]
153
+ if (carried === undefined && key === 'path') return declared
154
+ return renderValue(carried)
155
+ }
156
+
157
+ /**
158
+ * Resolve the computed parts of one field spec.
159
+ * @param spec - the field spec.
160
+ * @param mount - the matched mount, for a mount-level diff.
161
+ * @param execution - the pending call.
162
+ * @param settings - `{ timeoutMs, warn }`.
163
+ * @returns `{ kind: 'text' | 'diff' | 'error', ... }`, or undefined when the spec computes nothing.
164
+ */
165
+ async function resolveSpec(spec, mount, execution, settings) {
166
+ const requested = diffOptionOf(spec, mount)
167
+ const value = isResolver(spec.value) ? spec.value : undefined
168
+ const sides = requested === undefined
169
+ ? []
170
+ : ['path', 'before', 'after'].filter(key => isResolver(requested[key]))
171
+ if (value === undefined && sides.length === 0) return undefined
172
+ try {
173
+ if (sides.length === 0) {
174
+ const computed = await callResolver(value, execution, settings.timeoutMs, 'fields[].value')
175
+ return { kind: 'text', text: renderComputed(computed) }
176
+ }
177
+ const [path, oldText, newText] = await Promise.all([
178
+ readDiffSide(requested.path, 'path', execution, settings.timeoutMs),
179
+ readDiffSide(requested.before, 'before', execution, settings.timeoutMs),
180
+ readDiffSide(requested.after, 'after', execution, settings.timeoutMs),
181
+ ])
182
+ return { kind: 'diff', diff: { path, oldText, newText } }
183
+ } catch (error) {
184
+ const message = error instanceof Error ? error.message : String(error)
185
+ settings.warn(`a computed field could not be resolved (${message}); the panel shows the failure instead`)
186
+ // Degrade to a visible field, never to a blocked gate: the human can still
187
+ // decide on the call itself, and every other field still renders.
188
+ return { kind: 'error', message: `⚠️ 无法解析该字段:${message}` }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Resolve every computed field of one mount against the pending call.
194
+ *
195
+ * Only specs that declare a resolver are resolved, so a mount made of plain
196
+ * argument fields pays nothing and keeps its proposal unchanged.
197
+ *
198
+ * @param execution - the pending call.
199
+ * @param mount - the matched mount.
200
+ * @param options - `{ timeoutMs, warn }`; defaults to `RESOLVE_TIMEOUT_MS` and silence.
201
+ * @returns `{ fields: Map<spec, result>, mountDiff? }`, to hand to `buildRequest`.
202
+ */
203
+ export async function resolveFields(execution, mount, options = {}) {
204
+ const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0
205
+ ? options.timeoutMs
206
+ : RESOLVE_TIMEOUT_MS
207
+ const settings = {
208
+ timeoutMs,
209
+ warn: typeof options.warn === 'function' ? options.warn : () => {},
210
+ }
211
+ const fields = new Map()
212
+ if (mount.options.fields !== undefined) {
213
+ for (const spec of mount.options.fields) {
214
+ const resolved = await resolveSpec(spec, mount, execution, settings)
215
+ if (resolved !== undefined) fields.set(spec, resolved)
216
+ }
217
+ }
218
+ // Without an explicit field list, `buildFields` derives one field per
219
+ // parameter and appends the mount's diff as a field of its own; that diff
220
+ // computes on its own too.
221
+ const mountDiff = mount.options.fields === undefined
222
+ ? await resolveSpec({ param: 'diff' }, mount, execution, settings)
223
+ : undefined
224
+ return { fields, mountDiff }
225
+ }
226
+
227
+ /** Whether a mount asks the host to compute any part of its proposal. */
228
+ export function hasComputed(options) {
229
+ const computed = requested => requested !== undefined && requested !== null
230
+ && ['path', 'before', 'after'].some(key => isResolver(requested[key]))
231
+ if (computed(options.diff)) return true
232
+ return (options.fields ?? []).some(spec => isResolver(spec.value) || computed(spec.diff))
233
+ }
234
+
235
+ /** Bound one side of a diff by lines, marking what was cut. */
236
+ function boundDiffSide(text) {
237
+ const lines = text.split('\n')
238
+ if (lines.length <= LIMITS.maxDiffLines) return text
239
+ return [
240
+ ...lines.slice(0, LIMITS.maxDiffLines),
241
+ `… [已截断,另有 ${lines.length - LIMITS.maxDiffLines} 行未显示]`,
242
+ ].join('\n')
243
+ }
244
+
245
+ /** Bound both sides of one diff, so a whole-file diff cannot become an unbounded frame. */
246
+ function boundDiff(diff) {
247
+ return { ...diff, oldText: boundDiffSide(diff.oldText), newText: boundDiffSide(diff.newText) }
248
+ }
249
+
250
+ /** Label of the field a mount-level `diff` produces when its sides are computed. */
251
+ function diffPairLabel(requested) {
252
+ const side = entry => (typeof entry === 'string' ? entry : 'computed')
253
+ return `${side(requested.before)}→${side(requested.after)}`
254
+ }
255
+
83
256
  /**
84
257
  * Build the wire fields of one request.
85
258
  * @param execution - the pending tool execution.
86
259
  * @param mount - the matched mount.
260
+ * @param resolution - optional `resolveFields` result for this call.
87
261
  * @returns an ordered field array, values already rendered and truncated.
88
262
  */
89
- export function buildFields(execution, mount) {
263
+ export function buildFields(execution, mount, resolution) {
90
264
  const options = mount.options
91
265
  const args = argumentsOf(execution)
92
- const mountDiff = options.diff === undefined || options.diff === null ? undefined : options.diff
266
+ const mountDiff = diffOptionOf({}, mount)
93
267
  // A mount-level diff pairs two parameters, so those parameters must not also
94
268
  // appear as their own fields in the defaulted list.
95
269
  const paired = options.fields === undefined && mountDiff !== undefined
@@ -106,18 +280,33 @@ export function buildFields(execution, mount) {
106
280
  const raw = param === '(arguments)' && args === undefined
107
281
  ? execution.arguments
108
282
  : (args === undefined ? undefined : args[param])
109
- const render = spec.render ?? (diffSpec(spec, mount, execution) === undefined ? inferRender(raw) : 'diff')
110
- const diff = render === 'diff' ? diffSpec(spec, mount, execution) : undefined
283
+ const computed = resolution === undefined || resolution.fields === undefined
284
+ ? undefined
285
+ : resolution.fields.get(spec)
286
+ const computedDiff = computed !== undefined && computed.kind === 'diff' ? computed.diff : undefined
287
+ const render = computed !== undefined && computed.kind === 'error'
288
+ ? 'text'
289
+ : computedDiff !== undefined
290
+ ? 'diff'
291
+ : spec.render ?? (diffSpec(spec, mount, execution) === undefined ? inferRender(raw) : 'diff')
292
+ const diff = computedDiff ?? (render === 'diff' ? diffSpec(spec, mount, execution) : undefined)
111
293
  const title = spec.title
112
294
  ?? (typeof schema?.title === 'string' ? schema.title : undefined)
113
295
  ?? param
114
296
  const description = spec.description
115
297
  ?? (typeof schema?.description === 'string' ? schema.description : undefined)
116
298
  const editable = spec.editable ?? defaultEditable(render)
299
+ // A literal `value` replaces the parameter text; a function `value` was
300
+ // already turned into `computed` before this ran.
301
+ const declared = typeof spec.value === 'string' ? spec.value : undefined
117
302
  // A hidden field carries no value: it exists only to stay out of the panel.
118
- const value = diff === undefined && render !== 'hidden'
119
- ? truncate(renderValue(raw), options.maxFieldChars)
120
- : { text: '', truncated: false }
303
+ const value = computed !== undefined && computed.kind === 'error'
304
+ ? { text: computed.message, truncated: false }
305
+ : (computed !== undefined && computed.kind === 'text' && diff === undefined && render !== 'hidden'
306
+ ? truncate(computed.text, options.maxFieldChars)
307
+ : (diff === undefined && render !== 'hidden'
308
+ ? truncate(declared ?? renderValue(raw), options.maxFieldChars)
309
+ : { text: '', truncated: false }))
121
310
  fields.push({
122
311
  param,
123
312
  title,
@@ -125,20 +314,36 @@ export function buildFields(execution, mount) {
125
314
  render,
126
315
  editable: render === 'diff' || render === 'hidden' ? false : editable,
127
316
  labels: spec.labels ?? [],
128
- ...(diff === undefined ? { value: value.text, truncated: value.truncated } : { diff }),
317
+ ...(diff === undefined ? { value: value.text, truncated: value.truncated } : { diff: boundDiff(diff) }),
129
318
  })
130
319
  }
131
320
  if (options.fields === undefined && mountDiff !== undefined) {
132
- const diff = diffSpec({ diff: mountDiff }, mount, execution)
133
- if (diff !== undefined) {
321
+ const computed = resolution === undefined ? undefined : resolution.mountDiff
322
+ const title = typeof options.diff.title === 'string' ? options.diff.title : 'diff'
323
+ if (computed !== undefined && computed.kind === 'error') {
134
324
  fields.push({
135
- param: `${options.diff.before}→${options.diff.after}`,
136
- title: typeof options.diff.title === 'string' ? options.diff.title : 'diff',
137
- render: 'diff',
325
+ param: 'diff',
326
+ title,
327
+ render: 'text',
138
328
  editable: false,
139
329
  labels: [],
140
- diff,
330
+ value: computed.message,
331
+ truncated: false,
141
332
  })
333
+ } else {
334
+ const diff = computed !== undefined && computed.kind === 'diff'
335
+ ? computed.diff
336
+ : diffSpec({ diff: mountDiff }, mount, execution)
337
+ if (diff !== undefined) {
338
+ fields.push({
339
+ param: diffPairLabel(options.diff),
340
+ title,
341
+ render: 'diff',
342
+ editable: false,
343
+ labels: [],
344
+ diff: boundDiff(diff),
345
+ })
346
+ }
142
347
  }
143
348
  }
144
349
  if (fields.length === 0) {
@@ -149,12 +354,12 @@ export function buildFields(execution, mount) {
149
354
 
150
355
  /**
151
356
  * Build one complete wire request.
152
- * @param input - `{ execution, mount, sessionId, id, now }`.
357
+ * @param input - `{ execution, mount, sessionId, id, now, resolution }`.
153
358
  * @returns the request object broadcast to browsers.
154
359
  */
155
- export function buildRequest({ execution, mount, sessionId, id, now = Date.now() }) {
360
+ export function buildRequest({ execution, mount, sessionId, id, now = Date.now(), resolution }) {
156
361
  const options = mount.options
157
- const fields = buildFields(execution, mount)
362
+ const fields = buildFields(execution, mount, resolution)
158
363
  const countdown = options.countdown === null ? null : {
159
364
  remainingMs: options.countdown.seconds * 1000,
160
365
  action: options.countdown.action,
package/lib/protocol.js CHANGED
@@ -30,6 +30,19 @@ export const LIMITS = {
30
30
  maxFieldChars: 20000,
31
31
  /** Longest decision text folded into one model-facing message. */
32
32
  maxDecisionChars: 8000,
33
+ /**
34
+ * Lines kept per side of one diff. Deliberately far above what a reader
35
+ * compares by eye: it exists so a computed diff of a whole file cannot become
36
+ * an unbounded SSE frame, not to shorten small ones. The browser half stops
37
+ * aligning side by side past 2000 lines of its own, so a cut here is the only
38
+ * one worth announcing.
39
+ */
40
+ maxDiffLines: 2000,
41
+ /**
42
+ * Default bound on one computed field's resolver. Caller-supplied code can
43
+ * hang, and the gate must not hang with it.
44
+ */
45
+ resolveTimeoutMs: 5000,
33
46
  }
34
47
 
35
48
  /** Failure vocabulary shared by the HTTP replies and the panel's error state. */
package/lib/resolve.js CHANGED
@@ -42,6 +42,7 @@ export const DEFAULT_OPTIONS = {
42
42
  whenUnavailable: 'reject',
43
43
  enabled: true,
44
44
  maxFieldChars: LIMITS.maxFieldChars,
45
+ resolveTimeoutMs: LIMITS.resolveTimeoutMs,
45
46
  }
46
47
 
47
48
  /**
@@ -150,6 +151,45 @@ function normalizeButtons(value, report) {
150
151
  return buttons
151
152
  }
152
153
 
154
+ /** One side of a diff: the name of a parameter, or a function computing the text. */
155
+ function isDiffSource(value) {
156
+ return typeof value === 'string' || typeof value === 'function'
157
+ }
158
+
159
+ /**
160
+ * Check one `diff` option, at field or mount level.
161
+ *
162
+ * A malformed diff is reported and dropped rather than kept: keeping it would
163
+ * only make the field silently render as a markdown box instead of the diff the
164
+ * mount asked for, which is the harder failure to notice.
165
+ *
166
+ * @param value - the raw `diff` value.
167
+ * @param report - diagnostics sink.
168
+ * @param label - where the value came from, for the warning text.
169
+ * @returns the normalized diff, or undefined.
170
+ */
171
+ function normalizeDiff(value, report, label) {
172
+ if (value === undefined || value === null) return undefined
173
+ if (typeof value !== 'object' || Array.isArray(value)) {
174
+ report(`${label} must be an object like { before, after }`)
175
+ return undefined
176
+ }
177
+ if (!isDiffSource(value.before) || !isDiffSource(value.after)) {
178
+ report(`${label}.before and ${label}.after must each be a parameter name or a function of the call`)
179
+ return undefined
180
+ }
181
+ if (value.path !== undefined && !isDiffSource(value.path)) {
182
+ report(`${label}.path must be a parameter name, a literal path, or a function of the call`)
183
+ return undefined
184
+ }
185
+ return {
186
+ before: value.before,
187
+ after: value.after,
188
+ ...(value.path === undefined ? {} : { path: value.path }),
189
+ ...(typeof value.title === 'string' ? { title: value.title } : {}),
190
+ }
191
+ }
192
+
153
193
  function normalizeField(entry, report, index) {
154
194
  if (typeof entry === 'string') return { param: entry }
155
195
  if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
@@ -164,6 +204,13 @@ function normalizeField(entry, report, index) {
164
204
  report(`fields[${index}].render must be one of ${RENDER_MODES.join(', ')}`)
165
205
  return undefined
166
206
  }
207
+ // `value` overrides where the field's text comes from: a literal, or a
208
+ // function of the pending call that the host resolves before the panel opens.
209
+ if (entry.value !== undefined && typeof entry.value !== 'string' && typeof entry.value !== 'function') {
210
+ report(`fields[${index}].value must be a literal string or a function of the call`)
211
+ return undefined
212
+ }
213
+ const diff = normalizeDiff(entry.diff, report, `fields[${index}].diff`)
167
214
  return {
168
215
  param: entry.param,
169
216
  ...(typeof entry.title === 'string' ? { title: entry.title } : {}),
@@ -171,7 +218,8 @@ function normalizeField(entry, report, index) {
171
218
  ...(entry.render === undefined ? {} : { render: entry.render }),
172
219
  ...(entry.editable === undefined ? {} : { editable: entry.editable === true }),
173
220
  ...(Array.isArray(entry.labels) ? { labels: entry.labels.filter(label => typeof label === 'string') } : {}),
174
- ...(entry.diff === undefined ? {} : { diff: entry.diff }),
221
+ ...(entry.value === undefined ? {} : { value: entry.value }),
222
+ ...(diff === undefined ? {} : { diff }),
175
223
  }
176
224
  }
177
225
 
@@ -213,6 +261,9 @@ export function normalizeMount(input, source) {
213
261
  const maxFieldChars = Number.isSafeInteger(input.maxFieldChars) && input.maxFieldChars > 0
214
262
  ? input.maxFieldChars
215
263
  : DEFAULT_OPTIONS.maxFieldChars
264
+ const resolveTimeoutMs = Number.isSafeInteger(input.resolveTimeoutMs) && input.resolveTimeoutMs > 0
265
+ ? input.resolveTimeoutMs
266
+ : DEFAULT_OPTIONS.resolveTimeoutMs
216
267
  const labels = Array.isArray(input.labels)
217
268
  ? input.labels.filter(label => typeof label === 'string' && label !== '')
218
269
  : []
@@ -236,13 +287,14 @@ export function normalizeMount(input, source) {
236
287
  labels,
237
288
  buttons: normalizeButtons(input.buttons, report),
238
289
  fields,
239
- diff: input.diff,
290
+ diff: normalizeDiff(input.diff, report, 'diff'),
240
291
  countdown,
241
292
  reject: normalizeReject(input.reject, report),
242
293
  modify: normalizeModify(input.modify, report),
243
294
  whenUnavailable: unavailable,
244
295
  enabled: input.enabled === undefined ? true : input.enabled,
245
296
  maxFieldChars,
297
+ resolveTimeoutMs,
246
298
  },
247
299
  }
248
300
  return { ok: true, mount, warnings }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hitl",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Human-in-the-loop gate for DSH tool calls: mount any tool behind an approve / modify / reject decision card in the DSH Web UI, with an optional countdown.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -3,8 +3,9 @@ import { describe, it } from 'node:test'
3
3
 
4
4
  import {
5
5
  REASON_PREFIX, buildFields, buildRequest, capReason, defaultFieldSpecs, describeDecision,
6
- inferRender, isRevision, renderValue, revisionContext, revisionText,
6
+ hasComputed, inferRender, isRevision, renderValue, resolveFields, revisionContext, revisionText,
7
7
  } from '../lib/fields.js'
8
+ import { LIMITS } from '../lib/protocol.js'
8
9
  import { normalizeMount } from '../lib/resolve.js'
9
10
 
10
11
  /** Normalize one mount through the same path the host half uses. */
@@ -267,3 +268,120 @@ describe('fields: decisions back to the model', () => {
267
268
  assert.equal(capped.includes('已截断'), true)
268
269
  })
269
270
  })
271
+
272
+ describe('fields: computed fields', () => {
273
+ const call = executionOf({ path: 'notes.md', content: 'new text' })
274
+
275
+ it('takes a literal or a function as a field value', async () => {
276
+ const mount = mountOf({
277
+ fields: [
278
+ { param: 'content', title: '正文', render: 'markdown', editable: false, value: execution => `读到 ${execution.arguments.path}` },
279
+ { param: 'note', render: 'text', editable: false, value: '固定文案' },
280
+ { param: 'path', render: 'text', editable: false },
281
+ ],
282
+ })
283
+ const fields = buildFields(call, mount, await resolveFields(call, mount))
284
+ assert.equal(fields[0].value, '读到 notes.md')
285
+ assert.equal(fields[0].editable, false)
286
+ assert.equal(fields[1].value, '固定文案')
287
+ // A field without a resolver keeps reading its own argument.
288
+ assert.equal(fields[2].value, 'notes.md')
289
+ })
290
+
291
+ it('awaits an async resolver and renders a non-string result', async () => {
292
+ const mount = mountOf({
293
+ fields: [
294
+ { param: 'a', render: 'text', editable: false, value: async () => 'awaited' },
295
+ { param: 'b', render: 'text', editable: false, value: () => ({ lines: 2 }) },
296
+ ],
297
+ })
298
+ const fields = buildFields(call, mount, await resolveFields(call, mount))
299
+ assert.equal(fields[0].value, 'awaited')
300
+ assert.equal(fields[1].value, '{\n "lines": 2\n}')
301
+ })
302
+
303
+ it('renders a computed diff against a literal path', async () => {
304
+ const mount = mountOf({
305
+ fields: [{
306
+ param: 'index', title: '索引变化', render: 'diff', editable: false,
307
+ diff: { path: 'index.md', before: async () => 'old line', after: 'content' },
308
+ }],
309
+ })
310
+ const [field] = buildFields(call, mount, await resolveFields(call, mount))
311
+ assert.equal(field.render, 'diff')
312
+ assert.equal(field.editable, false)
313
+ assert.deepEqual(field.diff, { path: 'index.md', oldText: 'old line', newText: 'new text' })
314
+ })
315
+
316
+ it('resolves a mount-level diff that carries no field list', async () => {
317
+ const mount = mountOf({ diff: { path: () => 'tree.md', before: async () => 'a', after: () => 'b' } })
318
+ const fields = buildFields(call, mount, await resolveFields(call, mount))
319
+ assert.deepEqual(fields.map(field => field.param), ['path', 'content', 'computed→computed'])
320
+ assert.deepEqual(fields[2].diff, { path: 'tree.md', oldText: 'a', newText: 'b' })
321
+ })
322
+
323
+ it('shows a failing resolver in its own field instead of blocking the gate', async () => {
324
+ const mount = mountOf({
325
+ fields: [
326
+ { param: 'path', render: 'text', editable: false },
327
+ { param: 'ghost', render: 'markdown', editable: false, value: () => { throw new Error('磁盘不可读') } },
328
+ ],
329
+ })
330
+ const warnings = []
331
+ const fields = buildFields(call, mount, await resolveFields(call, mount, { warn: message => warnings.push(message) }))
332
+ assert.equal(fields[0].value, 'notes.md')
333
+ assert.equal(fields[1].render, 'text')
334
+ assert.equal(fields[1].editable, false)
335
+ assert.equal(fields[1].value.includes('磁盘不可读'), true)
336
+ assert.equal(warnings.length, 1)
337
+ })
338
+
339
+ it('bounds a hanging resolver with the mount timeout', async () => {
340
+ const mount = mountOf({ resolveTimeoutMs: 20, fields: [{ param: 'slow', value: () => new Promise(() => {}) }] })
341
+ const resolution = await resolveFields(call, mount, { timeoutMs: mount.options.resolveTimeoutMs })
342
+ const [field] = buildFields(call, mount, resolution)
343
+ assert.equal(field.value.includes('timed out after 20ms'), true)
344
+ })
345
+
346
+ it('truncates a computed value at maxFieldChars', async () => {
347
+ const mount = mountOf({ maxFieldChars: 4, fields: [{ param: 'long', value: () => 'abcdefgh' }] })
348
+ const [field] = buildFields(call, mount, await resolveFields(call, mount))
349
+ assert.equal(field.truncated, true)
350
+ assert.match(field.value, /已截断,共 8 字/)
351
+ })
352
+
353
+ it('bounds each side of a computed diff by lines', async () => {
354
+ const long = Array.from({ length: LIMITS.maxDiffLines + 2 }, (_entry, index) => `L${index}`).join('\n')
355
+ const mount = mountOf({
356
+ fields: [{ param: 'big', render: 'diff', editable: false, diff: { before: () => '', after: () => long } }],
357
+ })
358
+ const [field] = buildFields(call, mount, await resolveFields(call, mount))
359
+ assert.equal(field.diff.newText.split('\n').length, LIMITS.maxDiffLines + 1)
360
+ assert.match(field.diff.newText, /已截断,另有 2 行未显示/)
361
+ })
362
+
363
+ it('leaves a mount without resolvers exactly as it was', async () => {
364
+ const mount = mountOf({ fields: [{ param: 'content', render: 'markdown' }] })
365
+ const resolution = await resolveFields(call, mount)
366
+ assert.equal(resolution.fields.size, 0)
367
+ assert.equal(resolution.mountDiff, undefined)
368
+ assert.deepEqual(buildFields(call, mount, resolution), buildFields(call, mount))
369
+ })
370
+
371
+ it('reports which mounts compute a field', () => {
372
+ assert.equal(hasComputed(mountOf({}).options), false)
373
+ assert.equal(hasComputed(mountOf({ fields: [{ param: 'a', value: () => 'x' }] }).options), true)
374
+ assert.equal(hasComputed(mountOf({ diff: { before: 'old', after: () => 'x' } }).options), true)
375
+ assert.equal(hasComputed(mountOf({ fields: [{ param: 'a', diff: { before: 'old', after: () => 'x' } }] }).options), true)
376
+ })
377
+
378
+ it('carries a resolved diff through buildRequest', async () => {
379
+ const mount = mountOf({
380
+ fields: [{ param: 'index', title: '索引变化', render: 'diff', editable: false, diff: { path: 'index.md', before: () => 'old', after: 'content' } }],
381
+ })
382
+ const request = buildRequest({
383
+ execution: call, mount, sessionId: 's1', id: 'r1', now: 0, resolution: await resolveFields(call, mount),
384
+ })
385
+ assert.deepEqual(request.fields[0].diff, { path: 'index.md', oldText: 'old', newText: 'new text' })
386
+ })
387
+ })
@@ -61,6 +61,7 @@ describe('resolve: normalizeMount', () => {
61
61
  whenUnavailable: 'reject',
62
62
  enabled: true,
63
63
  maxFieldChars: DEFAULT_OPTIONS.maxFieldChars,
64
+ resolveTimeoutMs: DEFAULT_OPTIONS.resolveTimeoutMs,
64
65
  })
65
66
  })
66
67
 
@@ -111,6 +112,48 @@ describe('resolve: normalizeMount', () => {
111
112
  assert.match(warnings[0], /fields\[2\]\.render/)
112
113
  })
113
114
 
115
+ it('keeps a literal or computed field value and drops an unusable one', () => {
116
+ const read = () => 'value'
117
+ const { mount, warnings } = normalizeMount({
118
+ tool: 'edit',
119
+ fields: [
120
+ { param: 'note', value: '固定文案' },
121
+ { param: 'computed', value: read },
122
+ { param: 'content', value: undefined, diff: { path: 'file_path', before: read, after: 'new_string' } },
123
+ ],
124
+ }, 'src')
125
+ assert.equal(mount.options.fields[0].value, '固定文案')
126
+ assert.equal(mount.options.fields[1].value, read)
127
+ assert.deepEqual(Object.keys(mount.options.fields[2].diff), ['before', 'after', 'path'])
128
+ assert.equal(mount.options.fields[2].diff.before, read)
129
+ assert.equal(warnings.length, 0)
130
+
131
+ const bad = normalizeMount({ tool: 'edit', fields: [{ param: 'x', value: 7 }] }, 'src')
132
+ assert.deepEqual(bad.mount.options.fields, [])
133
+ assert.equal(bad.warnings.length, 1)
134
+ assert.match(bad.warnings[0], /fields\[0\]\.value/)
135
+ })
136
+
137
+ it('drops a malformed diff instead of letting the field render as markdown', () => {
138
+ const bad = normalizeMount({ tool: 'edit', diff: { before: 'old_string' } }, 'src')
139
+ assert.equal(bad.mount.options.diff, undefined)
140
+ assert.equal(bad.warnings.length, 1)
141
+ assert.match(bad.warnings[0], /^src: diff\.before and diff\.after/)
142
+
143
+ const fieldLevel = normalizeMount({
144
+ tool: 'edit', fields: [{ param: 'x', diff: { before: 'a', after: 'b', path: 7 } }],
145
+ }, 'src')
146
+ assert.equal('diff' in fieldLevel.mount.options.fields[0], false)
147
+ assert.equal(fieldLevel.warnings.length, 1)
148
+ assert.match(fieldLevel.warnings[0], /fields\[0\]\.diff\.path/)
149
+ })
150
+
151
+ it('bounds a computed field with resolveTimeoutMs, defaulting from the protocol', () => {
152
+ assert.equal(normalizeMount({ tool: 'bash' }, 'src').mount.options.resolveTimeoutMs, DEFAULT_OPTIONS.resolveTimeoutMs)
153
+ assert.equal(normalizeMount({ tool: 'bash', resolveTimeoutMs: 250 }, 'src').mount.options.resolveTimeoutMs, 250)
154
+ assert.equal(normalizeMount({ tool: 'bash', resolveTimeoutMs: 0 }, 'src').mount.options.resolveTimeoutMs, DEFAULT_OPTIONS.resolveTimeoutMs)
155
+ })
156
+
114
157
  it('reports a bad layout, unavailable policy, and modify mode', () => {
115
158
  const { mount, warnings } = normalizeMount({
116
159
  tool: 'bash', layout: 'grid', whenUnavailable: 'maybe', modify: { mode: 'ignore' }, maxFieldChars: -1, labels: ['ok', 3],