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 +41 -12
- package/README.zh.md +45 -13
- package/cordis.patch.yml +3 -2
- package/index.js +29 -2
- package/lib/fields.js +224 -19
- package/lib/protocol.js +13 -0
- package/lib/resolve.js +54 -2
- package/package.json +1 -1
- package/tests/fields.test.js +119 -1
- package/tests/resolve.test.js +43 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# dsh-hitl · Human-in-the-loop for
|
|
1
|
+
# dsh-hitl · Human-in-the-loop for DeepSeek Harness
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/dsh-hitl) [](LICENSE) [](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
4
4
|
|
|
@@ -99,17 +99,19 @@ export function apply(ctx) {
|
|
|
99
99
|
}
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
>
|
|
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;
|
|
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
|
|
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
|
|
392
|
-
│ ├── resolve.js
|
|
393
|
-
│ ├── fields.js
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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 #
|
|
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
|
-
>
|
|
109
|
-
>
|
|
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?)` |
|
|
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
|
|
483
|
+
├── index.js 666 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
|
|
452
484
|
├── client.js 1918 浏览器半:决策面板 + 帧级浮层(自包含 classic script,无 import)
|
|
453
485
|
├── lib/ 宿主半的纯逻辑:不碰 Cordis、不碰 DOM,可单独跑测试
|
|
454
|
-
│ ├── protocol.js
|
|
455
|
-
│ ├── resolve.js
|
|
456
|
-
│ ├── fields.js
|
|
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 测试,共
|
|
494
|
+
├── tests/ 零依赖 Node 测试,共 167 个用例
|
|
463
495
|
│ ├── client.test.js 818 (65)浏览器半的纯函数 + store / connection / seat
|
|
464
|
-
│ ├── fields.test.js
|
|
496
|
+
│ ├── fields.test.js 387 (37)提案推导、计算字段与决策文本
|
|
465
497
|
│ ├── pending.test.js 240 (18)状态机(注入时钟,无真实等待)
|
|
466
|
-
│ ├── resolve.test.js
|
|
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
|
|
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 #
|
|
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 {
|
|
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({
|
|
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
|
|
70
|
-
if (requested === 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 =
|
|
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
|
|
110
|
-
|
|
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 =
|
|
119
|
-
?
|
|
120
|
-
:
|
|
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
|
|
133
|
-
|
|
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:
|
|
136
|
-
title
|
|
137
|
-
render: '
|
|
325
|
+
param: 'diff',
|
|
326
|
+
title,
|
|
327
|
+
render: 'text',
|
|
138
328
|
editable: false,
|
|
139
329
|
labels: [],
|
|
140
|
-
|
|
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.
|
|
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
package/tests/fields.test.js
CHANGED
|
@@ -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
|
+
})
|
package/tests/resolve.test.js
CHANGED
|
@@ -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],
|