dsh-hitl 0.1.1 → 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 +74 -19
- package/README.zh.md +66 -18
- package/index.js +50 -13
- package/lib/fields.js +224 -19
- package/lib/protocol.js +13 -0
- package/lib/resolve.js +67 -6
- package/package.json +1 -2
- package/tests/docs.test.js +21 -16
- package/tests/fields.test.js +119 -1
- package/tests/manifest.test.js +16 -0
- package/tests/resolve.test.js +62 -0
- package/docs/01-panel-cn.png +0 -0
- package/docs/01-panel-en.png +0 -0
- package/docs/02-fields-cn.png +0 -0
- package/docs/02-fields-en.png +0 -0
- package/docs/03-subagent-cn.png +0 -0
- package/docs/03-subagent-en.png +0 -0
- package/docs/04-overlay-cn.png +0 -0
- package/docs/04-overlay-en.png +0 -0
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,9 +261,23 @@ 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
|
: []
|
|
270
|
+
const countdown = normalizeCountdown(input.countdown, report)
|
|
271
|
+
const unavailable = WHEN_UNAVAILABLE.includes(whenUnavailable)
|
|
272
|
+
? whenUnavailable
|
|
273
|
+
: DEFAULT_OPTIONS.whenUnavailable
|
|
274
|
+
// `wait` is fail-open by design: with no browser attached the call sits there
|
|
275
|
+
// until something ends it. Without a countdown, "something" means the session
|
|
276
|
+
// or the plugin, so an unattended run blocks an agent for as long as it lives.
|
|
277
|
+
// Say so at mount time rather than letting the first unattended run discover it.
|
|
278
|
+
if (unavailable === 'wait' && countdown === null) {
|
|
279
|
+
report('whenUnavailable: wait without a countdown blocks an unattended call until its session ends; pair it with countdown.seconds or use the fail-closed default')
|
|
280
|
+
}
|
|
219
281
|
const mount = {
|
|
220
282
|
describe: matcher.describe,
|
|
221
283
|
test: matcher.test,
|
|
@@ -225,15 +287,14 @@ export function normalizeMount(input, source) {
|
|
|
225
287
|
labels,
|
|
226
288
|
buttons: normalizeButtons(input.buttons, report),
|
|
227
289
|
fields,
|
|
228
|
-
diff: input.diff,
|
|
229
|
-
countdown
|
|
290
|
+
diff: normalizeDiff(input.diff, report, 'diff'),
|
|
291
|
+
countdown,
|
|
230
292
|
reject: normalizeReject(input.reject, report),
|
|
231
293
|
modify: normalizeModify(input.modify, report),
|
|
232
|
-
whenUnavailable:
|
|
233
|
-
? whenUnavailable
|
|
234
|
-
: DEFAULT_OPTIONS.whenUnavailable,
|
|
294
|
+
whenUnavailable: unavailable,
|
|
235
295
|
enabled: input.enabled === undefined ? true : input.enabled,
|
|
236
296
|
maxFieldChars,
|
|
297
|
+
resolveTimeoutMs,
|
|
237
298
|
},
|
|
238
299
|
}
|
|
239
300
|
return { ok: true, mount, warnings }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hitl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Human-in-the-loop gate for DSH tool calls: mount any tool behind an approve / modify / reject decision card in the DSH Web UI, with an optional countdown.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -39,7 +39,6 @@
|
|
|
39
39
|
"lib",
|
|
40
40
|
"tests",
|
|
41
41
|
"locale",
|
|
42
|
-
"docs",
|
|
43
42
|
"icon.svg",
|
|
44
43
|
"cordis.patch.yml",
|
|
45
44
|
"README.md",
|
package/tests/docs.test.js
CHANGED
|
@@ -15,6 +15,8 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
*/
|
|
16
16
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
17
17
|
|
|
18
|
+
const LANDING_PAGE = 'https://yunpengdon.github.io/dsh-hitl-landing/'
|
|
19
|
+
|
|
18
20
|
/** The English README is the package's front page; the Chinese one is its twin. */
|
|
19
21
|
const READMES = [
|
|
20
22
|
{ language: 'en', file: 'README.md', other: 'README.zh.md', suffix: '-en' },
|
|
@@ -62,21 +64,22 @@ describe('docs: the English and Chinese READMEs', () => {
|
|
|
62
64
|
}
|
|
63
65
|
})
|
|
64
66
|
|
|
65
|
-
it('
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
it('sends the reader to the landing page instead of shipping screenshots', () => {
|
|
68
|
+
// The four screenshots used to be the bulk of the published tarball. They
|
|
69
|
+
// live on the landing page now (its own repository carries them, in both
|
|
70
|
+
// languages), so the READMEs must link it and must not reference a local
|
|
71
|
+
// copy — and the package must not carry one either.
|
|
72
|
+
const manifest = JSON.parse(read('package.json'))
|
|
73
|
+
assert.equal(manifest.files.includes('docs'), false, 'screenshots must stay out of the tarball')
|
|
68
74
|
for (const readme of READMES) {
|
|
69
|
-
const
|
|
70
|
-
assert.equal(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
for (const name of available) {
|
|
79
|
-
assert.equal(referenced.has(name), true, `docs/${name} is shipped but never shown`)
|
|
75
|
+
const text = read(readme.file)
|
|
76
|
+
assert.equal(text.includes(LANDING_PAGE), true, `${readme.file} must link the landing page`)
|
|
77
|
+
assert.equal(
|
|
78
|
+
/!\[[^\]]*\]\(docs\//.test(text), false,
|
|
79
|
+
`${readme.file} must not embed a local screenshot`,
|
|
80
|
+
)
|
|
81
|
+
const header = text.split('\n').slice(0, 8).join('\n')
|
|
82
|
+
assert.equal(header.includes(LANDING_PAGE), true, `${readme.file} must offer the tour near the top`)
|
|
80
83
|
}
|
|
81
84
|
})
|
|
82
85
|
|
|
@@ -108,9 +111,11 @@ describe('docs: the English and Chinese READMEs', () => {
|
|
|
108
111
|
}
|
|
109
112
|
})
|
|
110
113
|
|
|
111
|
-
it('keeps the screenshot
|
|
114
|
+
it('publishes both READMEs and keeps the screenshot sources in the repository only', () => {
|
|
112
115
|
const manifest = JSON.parse(read('package.json'))
|
|
113
|
-
assert.equal(manifest.files.includes('docs'), true, '`docs` must be published with the screenshots')
|
|
114
116
|
assert.equal(manifest.files.includes('README.zh.md'), true, 'the Chinese README must be published too')
|
|
117
|
+
// The landing page's own repository carries the screenshots, so a second
|
|
118
|
+
// copy inside the package would only double the download for every install.
|
|
119
|
+
assert.equal(manifest.files.includes('docs'), false, 'screenshot sources must stay out of the tarball')
|
|
115
120
|
})
|
|
116
121
|
})
|
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
|
+
})
|