dsh-hitl 0.1.2 → 0.2.0
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 +36 -9
- package/README.zh.md +34 -8
- 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
|
|
|
@@ -146,6 +146,7 @@ Without `fields`, the proposal is **every parameter of this call**:
|
|
|
146
146
|
| `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
147
|
| `enabled` | boolean \| `(exec) => boolean` | `true` | Turn a mount off temporarily / make it conditional |
|
|
148
148
|
| `maxFieldChars` | number | `20000` | Per-field render cap; longer values are truncated with a note |
|
|
149
|
+
| `resolveTimeoutMs` | number | `5000` | Bound on resolving one **computed** field; a timeout or a throw fails that field alone, never the call |
|
|
149
150
|
|
|
150
151
|
### FieldSpec
|
|
151
152
|
|
|
@@ -154,6 +155,7 @@ Without `fields`, the proposal is **every parameter of this call**:
|
|
|
154
155
|
render: 'markdown', // markdown | text | diff | json | hidden
|
|
155
156
|
editable: true, // overrides the default editability
|
|
156
157
|
labels: ['irreversible'], // field-level labels
|
|
158
|
+
value: 'fixed copy', // this field's text: a literal, or (exec) => string | Promise<string>
|
|
157
159
|
diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // used when render: 'diff'
|
|
158
160
|
```
|
|
159
161
|
|
|
@@ -161,6 +163,31 @@ Without `fields`, the proposal is **every parameter of this call**:
|
|
|
161
163
|
- When `fields` is given, **only** the listed fields are shown (parameters you leave out never reach the panel).
|
|
162
164
|
- Config rows may shorten a spec to a bare string: `fields: ['command', { param: 'cwd', title: 'Working directory' }]`.
|
|
163
165
|
|
|
166
|
+
### Computed fields: putting what the arguments do not carry on the card
|
|
167
|
+
|
|
168
|
+
`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.
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
ctx.hitl.protect('update_index', {
|
|
172
|
+
title: 'Update the knowledge-tree index',
|
|
173
|
+
fields: [
|
|
174
|
+
{ param: 'content', title: 'New index text', render: 'markdown' },
|
|
175
|
+
{ param: 'index', title: 'What changes in the index file', render: 'diff', editable: false,
|
|
176
|
+
// `before` reads the old index from disk, `after` derives the new file text —
|
|
177
|
+
// neither of them is one of the call's parameters
|
|
178
|
+
diff: { path: 'index.md',
|
|
179
|
+
before: async exec => readIndexFile(exec),
|
|
180
|
+
after: exec => `# Knowledge tree index\n\n${exec.arguments.content}` } },
|
|
181
|
+
],
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- 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.
|
|
186
|
+
- Resolvers may be async; one field's resolution is bounded by `resolveTimeoutMs` (5s by default).
|
|
187
|
+
- **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.
|
|
188
|
+
- The wire format and the browser half are unchanged: both sides of a diff always travelled in the frame; the panel just renders them.
|
|
189
|
+
- Each side of a diff is cut past `maxDiffLines` (2000 lines) with a note, so a whole-file diff cannot become an unbounded frame.
|
|
190
|
+
|
|
164
191
|
### The three endings of a countdown
|
|
165
192
|
|
|
166
193
|
`countdown.action` decides what the host does when the countdown reaches zero:
|
|
@@ -385,22 +412,22 @@ Reply: `{ ok: true, accepted: true, held?, expiresAt? }` or
|
|
|
385
412
|
|
|
386
413
|
```text
|
|
387
414
|
dsh-hitl/
|
|
388
|
-
├── index.js
|
|
415
|
+
├── index.js 666 host half: service + gate + countdown/hold + SSE & decision routes + token injection
|
|
389
416
|
├── client.js 1918 browser half: decision panel + frame-level notice (self-contained classic script, no imports)
|
|
390
417
|
├── lib/ the host half's pure logic: no Cordis, no DOM, testable on its own
|
|
391
|
-
│ ├── protocol.js
|
|
392
|
-
│ ├── resolve.js
|
|
393
|
-
│ ├── fields.js
|
|
418
|
+
│ ├── protocol.js 195 frame and uplink validation, limits, error codes (the single source of the protocol)
|
|
419
|
+
│ ├── resolve.js 363 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
|
|
420
|
+
│ ├── fields.js 449 default proposal derivation, diff pairing, computed-field resolution, decision → model-visible text
|
|
394
421
|
│ └── pending.js 217 the pending-decision state machine (injectable clock): countdown, hold, settle-once
|
|
395
422
|
├── locale/ the plugin list's name and description (shape must be {"meta":{...}}, see §6.3)
|
|
396
423
|
│ ├── en.json 6
|
|
397
424
|
│ └── zh.json 6
|
|
398
425
|
├── docs/ screenshot sources, deliberately NOT shipped: the README links the landing page instead
|
|
399
|
-
├── tests/ zero-dependency Node tests,
|
|
426
|
+
├── tests/ zero-dependency Node tests, 167 cases in total
|
|
400
427
|
│ ├── client.test.js 818 (65) the browser half's pure helpers + store / connection / seat
|
|
401
|
-
│ ├── fields.test.js
|
|
428
|
+
│ ├── fields.test.js 387 (37) proposal derivation, computed fields and decision text
|
|
402
429
|
│ ├── pending.test.js 240 (18) the state machine (injected clock, no real waiting)
|
|
403
|
-
│ ├── resolve.test.js
|
|
430
|
+
│ ├── resolve.test.js 224 (22) matchers and option normalization
|
|
404
431
|
│ ├── protocol.test.js 117 (16) frame and uplink validation
|
|
405
432
|
│ ├── docs.test.js 121 (5) the two READMEs, the landing-page link, and the numbers in this very table
|
|
406
433
|
│ └── manifest.test.js 77 (4) package metadata and the locale resource shape
|
|
@@ -436,7 +463,7 @@ dsh-hitl/
|
|
|
436
463
|
|
|
437
464
|
```sh
|
|
438
465
|
node --check index.js client.js lib/*.js # syntax
|
|
439
|
-
npm test #
|
|
466
|
+
npm test # 167 zero-dependency cases: pure functions, state machines, the channel
|
|
440
467
|
npm run check # both of the above (syntax + the whole suite)
|
|
441
468
|
```
|
|
442
469
|
|
package/README.zh.md
CHANGED
|
@@ -159,6 +159,7 @@ export function apply(ctx) {
|
|
|
159
159
|
| `whenUnavailable` | `reject` \| `wait` | `reject` | 没有任何浏览器连接时的行为:`reject` = fail-closed;`wait` = 一直等,**务必配 `countdown`**,否则那次调用会挂到会话结束(挂载时宿主会给 `dsh-hitl:` 警告),见 §7.1 |
|
|
160
160
|
| `enabled` | boolean \| `(exec) => boolean` | `true` | 临时关闭 / 条件挂载 |
|
|
161
161
|
| `maxFieldChars` | number | `20000` | 单字段渲染上限,超出会截断并提示 |
|
|
162
|
+
| `resolveTimeoutMs` | number | `5000` | 单个**计算字段**的解析上限;超时或抛错只让那个字段显示失败,不阻断这次调用 |
|
|
162
163
|
|
|
163
164
|
### FieldSpec
|
|
164
165
|
|
|
@@ -167,6 +168,7 @@ export function apply(ctx) {
|
|
|
167
168
|
render: 'markdown', // markdown | text | diff | json | hidden
|
|
168
169
|
editable: true, // 覆盖默认可编辑性
|
|
169
170
|
labels: ['不可撤销'], // 字段级标签
|
|
171
|
+
value: '固定文案', // 该字段的文本:字面量,或 (exec) => string | Promise<string>
|
|
170
172
|
diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // render: 'diff' 时用
|
|
171
173
|
```
|
|
172
174
|
|
|
@@ -174,6 +176,30 @@ export function apply(ctx) {
|
|
|
174
176
|
- `fields` 给定时**只显示**列出的字段(未列出的参数不会出现在面板里)。
|
|
175
177
|
- 配置行里可以简写成字符串:`fields: ['command', { param: 'cwd', title: '工作目录' }]`。
|
|
176
178
|
|
|
179
|
+
### 计算字段:把参数之外的东西放进卡里
|
|
180
|
+
|
|
181
|
+
`diff.before` / `diff.after` / `diff.path` 平时填的是**参数名**(宿主取 `args[名字]`)。它们与字段的 `value` 一样,也可以填**函数**:宿主在弹卡前用这次调用的 `exec` 调它,把返回值当作文本。
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
ctx.hitl.protect('update_index', {
|
|
185
|
+
title: '更新知识树索引',
|
|
186
|
+
fields: [
|
|
187
|
+
{ param: 'content', title: '新索引正文', render: 'markdown' },
|
|
188
|
+
{ param: 'index', title: '索引文件的变化', render: 'diff', editable: false,
|
|
189
|
+
// before 读磁盘上的旧索引,after 由新正文推出来——两者都不在参数里
|
|
190
|
+
diff: { path: 'index.md',
|
|
191
|
+
before: async exec => readIndexFile(exec),
|
|
192
|
+
after: exec => `# 知识树索引\n\n${exec.arguments.content}` } },
|
|
193
|
+
],
|
|
194
|
+
})
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- 字符串形式的 `diff.path` 若**不是**某个参数名,就当作字面路径使用:被改的文件常常不是这次调用的参数。
|
|
198
|
+
- 函数可以是 async;每个字段的解析上限是 `resolveTimeoutMs`(默认 5s)。
|
|
199
|
+
- **解析失败不阻断**:函数抛错或超时,只会把**那个字段**变成一行 `⚠️ 无法解析该字段:…`,卡照常弹出、人照常决策。整个解析过程坏掉时,提案退回到"参数原样",而不是拒绝调用。
|
|
200
|
+
- wire 格式与浏览器半都没有变化:diff 的两侧本来就在帧里,前端只是照常渲染。
|
|
201
|
+
- diff 的每一侧超过 `maxDiffLines`(2000 行)会被截断并标注,避免整文件 diff 变成无上限的一帧。
|
|
202
|
+
|
|
177
203
|
### 倒计时的三种结局
|
|
178
204
|
|
|
179
205
|
`countdown.action` 决定倒计时归零后宿主怎么判:
|
|
@@ -448,22 +474,22 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
|
|
|
448
474
|
|
|
449
475
|
```text
|
|
450
476
|
dsh-hitl/
|
|
451
|
-
├── index.js
|
|
477
|
+
├── index.js 666 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
|
|
452
478
|
├── client.js 1918 浏览器半:决策面板 + 帧级浮层(自包含 classic script,无 import)
|
|
453
479
|
├── lib/ 宿主半的纯逻辑:不碰 Cordis、不碰 DOM,可单独跑测试
|
|
454
|
-
│ ├── protocol.js
|
|
455
|
-
│ ├── resolve.js
|
|
456
|
-
│ ├── fields.js
|
|
480
|
+
│ ├── protocol.js 195 帧与上行校验、上限、错误码(协议规范的唯一出处)
|
|
481
|
+
│ ├── resolve.js 363 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
|
|
482
|
+
│ ├── fields.js 449 默认提案推导、diff 配对、计算字段解析、决策 → 模型可见文本
|
|
457
483
|
│ └── pending.js 217 待决策状态机(可注入时钟):倒计时、持握、恰好一次的结算
|
|
458
484
|
├── locale/ 插件列表里的名字与简介(形状必须是 {"meta":{...}},见 §6.3)
|
|
459
485
|
│ ├── en.json 6
|
|
460
486
|
│ └── zh.json 6
|
|
461
487
|
├── docs/ 截图源文件,**故意不进 npm 包**:README 改为链接 landing page
|
|
462
|
-
├── tests/ 零依赖 Node 测试,共
|
|
488
|
+
├── tests/ 零依赖 Node 测试,共 167 个用例
|
|
463
489
|
│ ├── client.test.js 818 (65)浏览器半的纯函数 + store / connection / seat
|
|
464
|
-
│ ├── fields.test.js
|
|
490
|
+
│ ├── fields.test.js 387 (37)提案推导、计算字段与决策文本
|
|
465
491
|
│ ├── pending.test.js 240 (18)状态机(注入时钟,无真实等待)
|
|
466
|
-
│ ├── resolve.test.js
|
|
492
|
+
│ ├── resolve.test.js 224 (22)matcher 与选项归一化
|
|
467
493
|
│ ├── protocol.test.js 117 (16)帧与上行校验
|
|
468
494
|
│ ├── docs.test.js 121 (5)两份 README、landing page 链接,以及上面这张表里的每个数字
|
|
469
495
|
│ └── manifest.test.js 77 (4)包元数据与 locale 资源形状
|
|
@@ -505,7 +531,7 @@ dsh-hitl/
|
|
|
505
531
|
|
|
506
532
|
```sh
|
|
507
533
|
node --check index.js client.js lib/*.js # 语法
|
|
508
|
-
npm test #
|
|
534
|
+
npm test # 167 个纯函数/状态机/通道用例,零依赖
|
|
509
535
|
npm run check # 上面两步合起来(语法 + 全部用例)
|
|
510
536
|
```
|
|
511
537
|
|
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],
|