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/lib/fields.js CHANGED
@@ -6,6 +6,11 @@
6
6
  * Defaults follow the plugin's core promise: with no configuration at all, the
7
7
  * proposal is the call's own parameters — one field per parameter, titled by the
8
8
  * parameter's key, rendered as an editable markdown box.
9
+ *
10
+ * A mount that must show something the arguments do not carry — the file a call
11
+ * is about to overwrite, the node it is about to delete — declares it as a
12
+ * function; `resolveFields` runs those before the request is built. The browser
13
+ * half stays a pure renderer of the wire shape and needs no change for them.
9
14
  */
10
15
 
11
16
  import { LIMITS, truncate } from './protocol.js'
@@ -14,6 +19,9 @@ import { defaultEditable } from './resolve.js'
14
19
  /** Model-facing prefix every reason this plugin produces starts with. */
15
20
  export const REASON_PREFIX = 'HITL'
16
21
 
22
+ /** Default bound on one computed field, used when the mount sets no `resolveTimeoutMs`. */
23
+ export const RESOLVE_TIMEOUT_MS = LIMITS.resolveTimeoutMs
24
+
17
25
  /** Render a parameter value as proposal text. */
18
26
  export function renderValue(value) {
19
27
  if (typeof value === 'string') return value
@@ -36,6 +44,25 @@ export function inferRender() {
36
44
  return 'markdown'
37
45
  }
38
46
 
47
+ /** Whether a value is computed from the pending call instead of read from an argument. */
48
+ export function isResolver(value) {
49
+ return typeof value === 'function'
50
+ }
51
+
52
+ /**
53
+ * The diff option that applies to one field: the field's own, else the mount's.
54
+ *
55
+ * Shared by the renderer and the resolver on purpose: a field must never be
56
+ * resolved as a diff and then rendered as something else, or the other way
57
+ * round.
58
+ */
59
+ function diffOptionOf(spec, mount) {
60
+ const requested = spec.diff ?? mount.options.diff
61
+ return typeof requested === 'object' && requested !== null && !Array.isArray(requested)
62
+ ? requested
63
+ : undefined
64
+ }
65
+
39
66
  function schemaOf(execution) {
40
67
  const parameters = execution.schema?.parameters
41
68
  return typeof parameters === 'object' && parameters !== null ? parameters : undefined
@@ -66,10 +93,12 @@ export function defaultFieldSpecs(execution) {
66
93
  }
67
94
 
68
95
  function diffSpec(spec, mount, execution) {
69
- const requested = spec.diff ?? mount.options.diff
70
- if (requested === undefined || requested === null || typeof requested !== 'object') return undefined
96
+ const requested = diffOptionOf(spec, mount)
97
+ if (requested === undefined) return undefined
71
98
  const before = requested.before
72
99
  const after = requested.after
100
+ // Function sides are computed before the request is built (see
101
+ // `resolveFields`); this path only pairs two arguments.
73
102
  if (typeof before !== 'string' || typeof after !== 'string') return undefined
74
103
  const args = argumentsOf(execution)
75
104
  if (args === undefined) return undefined
@@ -80,16 +109,161 @@ function diffSpec(spec, mount, execution) {
80
109
  }
81
110
  }
82
111
 
112
+ // ── computed fields ─────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Await one resolver with a bound.
116
+ *
117
+ * A resolver is caller-supplied code, so it can hang; the gate must not hang
118
+ * with it. The losing side of the race keeps running — a promise cannot be
119
+ * cancelled — but nothing awaits it, and the timer is unref'd so a pending
120
+ * timeout never holds the process open on its own.
121
+ */
122
+ async function callResolver(resolver, execution, timeoutMs, label) {
123
+ let timer
124
+ try {
125
+ return await Promise.race([
126
+ Promise.resolve().then(() => resolver(execution)),
127
+ new Promise((_resolve, reject) => {
128
+ timer = setTimeout(() => { reject(new Error(`${label} timed out after ${timeoutMs}ms`)) }, timeoutMs)
129
+ if (typeof timer?.unref === 'function') timer.unref()
130
+ }),
131
+ ])
132
+ } finally {
133
+ clearTimeout(timer)
134
+ }
135
+ }
136
+
137
+ /** Render whatever a resolver returned as field text. */
138
+ function renderComputed(value) {
139
+ return value === undefined || value === null ? '' : renderValue(value)
140
+ }
141
+
142
+ /**
143
+ * Read one diff side: a resolver computes it, a string names the argument that
144
+ * carries it. `path` may also be a literal, because the file a call is about to
145
+ * change is often not one of the call's parameters.
146
+ */
147
+ async function readDiffSide(declared, key, execution, timeoutMs) {
148
+ if (isResolver(declared)) {
149
+ return renderComputed(await callResolver(declared, execution, timeoutMs, `diff.${key}`))
150
+ }
151
+ const args = argumentsOf(execution)
152
+ const carried = args === undefined ? undefined : args[declared]
153
+ if (carried === undefined && key === 'path') return declared
154
+ return renderValue(carried)
155
+ }
156
+
157
+ /**
158
+ * Resolve the computed parts of one field spec.
159
+ * @param spec - the field spec.
160
+ * @param mount - the matched mount, for a mount-level diff.
161
+ * @param execution - the pending call.
162
+ * @param settings - `{ timeoutMs, warn }`.
163
+ * @returns `{ kind: 'text' | 'diff' | 'error', ... }`, or undefined when the spec computes nothing.
164
+ */
165
+ async function resolveSpec(spec, mount, execution, settings) {
166
+ const requested = diffOptionOf(spec, mount)
167
+ const value = isResolver(spec.value) ? spec.value : undefined
168
+ const sides = requested === undefined
169
+ ? []
170
+ : ['path', 'before', 'after'].filter(key => isResolver(requested[key]))
171
+ if (value === undefined && sides.length === 0) return undefined
172
+ try {
173
+ if (sides.length === 0) {
174
+ const computed = await callResolver(value, execution, settings.timeoutMs, 'fields[].value')
175
+ return { kind: 'text', text: renderComputed(computed) }
176
+ }
177
+ const [path, oldText, newText] = await Promise.all([
178
+ readDiffSide(requested.path, 'path', execution, settings.timeoutMs),
179
+ readDiffSide(requested.before, 'before', execution, settings.timeoutMs),
180
+ readDiffSide(requested.after, 'after', execution, settings.timeoutMs),
181
+ ])
182
+ return { kind: 'diff', diff: { path, oldText, newText } }
183
+ } catch (error) {
184
+ const message = error instanceof Error ? error.message : String(error)
185
+ settings.warn(`a computed field could not be resolved (${message}); the panel shows the failure instead`)
186
+ // Degrade to a visible field, never to a blocked gate: the human can still
187
+ // decide on the call itself, and every other field still renders.
188
+ return { kind: 'error', message: `⚠️ 无法解析该字段:${message}` }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Resolve every computed field of one mount against the pending call.
194
+ *
195
+ * Only specs that declare a resolver are resolved, so a mount made of plain
196
+ * argument fields pays nothing and keeps its proposal unchanged.
197
+ *
198
+ * @param execution - the pending call.
199
+ * @param mount - the matched mount.
200
+ * @param options - `{ timeoutMs, warn }`; defaults to `RESOLVE_TIMEOUT_MS` and silence.
201
+ * @returns `{ fields: Map<spec, result>, mountDiff? }`, to hand to `buildRequest`.
202
+ */
203
+ export async function resolveFields(execution, mount, options = {}) {
204
+ const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0
205
+ ? options.timeoutMs
206
+ : RESOLVE_TIMEOUT_MS
207
+ const settings = {
208
+ timeoutMs,
209
+ warn: typeof options.warn === 'function' ? options.warn : () => {},
210
+ }
211
+ const fields = new Map()
212
+ if (mount.options.fields !== undefined) {
213
+ for (const spec of mount.options.fields) {
214
+ const resolved = await resolveSpec(spec, mount, execution, settings)
215
+ if (resolved !== undefined) fields.set(spec, resolved)
216
+ }
217
+ }
218
+ // Without an explicit field list, `buildFields` derives one field per
219
+ // parameter and appends the mount's diff as a field of its own; that diff
220
+ // computes on its own too.
221
+ const mountDiff = mount.options.fields === undefined
222
+ ? await resolveSpec({ param: 'diff' }, mount, execution, settings)
223
+ : undefined
224
+ return { fields, mountDiff }
225
+ }
226
+
227
+ /** Whether a mount asks the host to compute any part of its proposal. */
228
+ export function hasComputed(options) {
229
+ const computed = requested => requested !== undefined && requested !== null
230
+ && ['path', 'before', 'after'].some(key => isResolver(requested[key]))
231
+ if (computed(options.diff)) return true
232
+ return (options.fields ?? []).some(spec => isResolver(spec.value) || computed(spec.diff))
233
+ }
234
+
235
+ /** Bound one side of a diff by lines, marking what was cut. */
236
+ function boundDiffSide(text) {
237
+ const lines = text.split('\n')
238
+ if (lines.length <= LIMITS.maxDiffLines) return text
239
+ return [
240
+ ...lines.slice(0, LIMITS.maxDiffLines),
241
+ `… [已截断,另有 ${lines.length - LIMITS.maxDiffLines} 行未显示]`,
242
+ ].join('\n')
243
+ }
244
+
245
+ /** Bound both sides of one diff, so a whole-file diff cannot become an unbounded frame. */
246
+ function boundDiff(diff) {
247
+ return { ...diff, oldText: boundDiffSide(diff.oldText), newText: boundDiffSide(diff.newText) }
248
+ }
249
+
250
+ /** Label of the field a mount-level `diff` produces when its sides are computed. */
251
+ function diffPairLabel(requested) {
252
+ const side = entry => (typeof entry === 'string' ? entry : 'computed')
253
+ return `${side(requested.before)}→${side(requested.after)}`
254
+ }
255
+
83
256
  /**
84
257
  * Build the wire fields of one request.
85
258
  * @param execution - the pending tool execution.
86
259
  * @param mount - the matched mount.
260
+ * @param resolution - optional `resolveFields` result for this call.
87
261
  * @returns an ordered field array, values already rendered and truncated.
88
262
  */
89
- export function buildFields(execution, mount) {
263
+ export function buildFields(execution, mount, resolution) {
90
264
  const options = mount.options
91
265
  const args = argumentsOf(execution)
92
- const mountDiff = options.diff === undefined || options.diff === null ? undefined : options.diff
266
+ const mountDiff = diffOptionOf({}, mount)
93
267
  // A mount-level diff pairs two parameters, so those parameters must not also
94
268
  // appear as their own fields in the defaulted list.
95
269
  const paired = options.fields === undefined && mountDiff !== undefined
@@ -106,18 +280,33 @@ export function buildFields(execution, mount) {
106
280
  const raw = param === '(arguments)' && args === undefined
107
281
  ? execution.arguments
108
282
  : (args === undefined ? undefined : args[param])
109
- const render = spec.render ?? (diffSpec(spec, mount, execution) === undefined ? inferRender(raw) : 'diff')
110
- const diff = render === 'diff' ? diffSpec(spec, mount, execution) : undefined
283
+ const computed = resolution === undefined || resolution.fields === undefined
284
+ ? undefined
285
+ : resolution.fields.get(spec)
286
+ const computedDiff = computed !== undefined && computed.kind === 'diff' ? computed.diff : undefined
287
+ const render = computed !== undefined && computed.kind === 'error'
288
+ ? 'text'
289
+ : computedDiff !== undefined
290
+ ? 'diff'
291
+ : spec.render ?? (diffSpec(spec, mount, execution) === undefined ? inferRender(raw) : 'diff')
292
+ const diff = computedDiff ?? (render === 'diff' ? diffSpec(spec, mount, execution) : undefined)
111
293
  const title = spec.title
112
294
  ?? (typeof schema?.title === 'string' ? schema.title : undefined)
113
295
  ?? param
114
296
  const description = spec.description
115
297
  ?? (typeof schema?.description === 'string' ? schema.description : undefined)
116
298
  const editable = spec.editable ?? defaultEditable(render)
299
+ // A literal `value` replaces the parameter text; a function `value` was
300
+ // already turned into `computed` before this ran.
301
+ const declared = typeof spec.value === 'string' ? spec.value : undefined
117
302
  // A hidden field carries no value: it exists only to stay out of the panel.
118
- const value = diff === undefined && render !== 'hidden'
119
- ? truncate(renderValue(raw), options.maxFieldChars)
120
- : { text: '', truncated: false }
303
+ const value = computed !== undefined && computed.kind === 'error'
304
+ ? { text: computed.message, truncated: false }
305
+ : (computed !== undefined && computed.kind === 'text' && diff === undefined && render !== 'hidden'
306
+ ? truncate(computed.text, options.maxFieldChars)
307
+ : (diff === undefined && render !== 'hidden'
308
+ ? truncate(declared ?? renderValue(raw), options.maxFieldChars)
309
+ : { text: '', truncated: false }))
121
310
  fields.push({
122
311
  param,
123
312
  title,
@@ -125,20 +314,36 @@ export function buildFields(execution, mount) {
125
314
  render,
126
315
  editable: render === 'diff' || render === 'hidden' ? false : editable,
127
316
  labels: spec.labels ?? [],
128
- ...(diff === undefined ? { value: value.text, truncated: value.truncated } : { diff }),
317
+ ...(diff === undefined ? { value: value.text, truncated: value.truncated } : { diff: boundDiff(diff) }),
129
318
  })
130
319
  }
131
320
  if (options.fields === undefined && mountDiff !== undefined) {
132
- const diff = diffSpec({ diff: mountDiff }, mount, execution)
133
- if (diff !== undefined) {
321
+ const computed = resolution === undefined ? undefined : resolution.mountDiff
322
+ const title = typeof options.diff.title === 'string' ? options.diff.title : 'diff'
323
+ if (computed !== undefined && computed.kind === 'error') {
134
324
  fields.push({
135
- param: `${options.diff.before}→${options.diff.after}`,
136
- title: typeof options.diff.title === 'string' ? options.diff.title : 'diff',
137
- render: 'diff',
325
+ param: 'diff',
326
+ title,
327
+ render: 'text',
138
328
  editable: false,
139
329
  labels: [],
140
- diff,
330
+ value: computed.message,
331
+ truncated: false,
141
332
  })
333
+ } else {
334
+ const diff = computed !== undefined && computed.kind === 'diff'
335
+ ? computed.diff
336
+ : diffSpec({ diff: mountDiff }, mount, execution)
337
+ if (diff !== undefined) {
338
+ fields.push({
339
+ param: diffPairLabel(options.diff),
340
+ title,
341
+ render: 'diff',
342
+ editable: false,
343
+ labels: [],
344
+ diff: boundDiff(diff),
345
+ })
346
+ }
142
347
  }
143
348
  }
144
349
  if (fields.length === 0) {
@@ -149,12 +354,12 @@ export function buildFields(execution, mount) {
149
354
 
150
355
  /**
151
356
  * Build one complete wire request.
152
- * @param input - `{ execution, mount, sessionId, id, now }`.
357
+ * @param input - `{ execution, mount, sessionId, id, now, resolution }`.
153
358
  * @returns the request object broadcast to browsers.
154
359
  */
155
- export function buildRequest({ execution, mount, sessionId, id, now = Date.now() }) {
360
+ export function buildRequest({ execution, mount, sessionId, id, now = Date.now(), resolution }) {
156
361
  const options = mount.options
157
- const fields = buildFields(execution, mount)
362
+ const fields = buildFields(execution, mount, resolution)
158
363
  const countdown = options.countdown === null ? null : {
159
364
  remainingMs: options.countdown.seconds * 1000,
160
365
  action: options.countdown.action,
package/lib/protocol.js CHANGED
@@ -30,6 +30,19 @@ export const LIMITS = {
30
30
  maxFieldChars: 20000,
31
31
  /** Longest decision text folded into one model-facing message. */
32
32
  maxDecisionChars: 8000,
33
+ /**
34
+ * Lines kept per side of one diff. Deliberately far above what a reader
35
+ * compares by eye: it exists so a computed diff of a whole file cannot become
36
+ * an unbounded SSE frame, not to shorten small ones. The browser half stops
37
+ * aligning side by side past 2000 lines of its own, so a cut here is the only
38
+ * one worth announcing.
39
+ */
40
+ maxDiffLines: 2000,
41
+ /**
42
+ * Default bound on one computed field's resolver. Caller-supplied code can
43
+ * hang, and the gate must not hang with it.
44
+ */
45
+ resolveTimeoutMs: 5000,
33
46
  }
34
47
 
35
48
  /** Failure vocabulary shared by the HTTP replies and the panel's error state. */
package/lib/resolve.js CHANGED
@@ -42,6 +42,7 @@ export const DEFAULT_OPTIONS = {
42
42
  whenUnavailable: 'reject',
43
43
  enabled: true,
44
44
  maxFieldChars: LIMITS.maxFieldChars,
45
+ resolveTimeoutMs: LIMITS.resolveTimeoutMs,
45
46
  }
46
47
 
47
48
  /**
@@ -150,6 +151,45 @@ function normalizeButtons(value, report) {
150
151
  return buttons
151
152
  }
152
153
 
154
+ /** One side of a diff: the name of a parameter, or a function computing the text. */
155
+ function isDiffSource(value) {
156
+ return typeof value === 'string' || typeof value === 'function'
157
+ }
158
+
159
+ /**
160
+ * Check one `diff` option, at field or mount level.
161
+ *
162
+ * A malformed diff is reported and dropped rather than kept: keeping it would
163
+ * only make the field silently render as a markdown box instead of the diff the
164
+ * mount asked for, which is the harder failure to notice.
165
+ *
166
+ * @param value - the raw `diff` value.
167
+ * @param report - diagnostics sink.
168
+ * @param label - where the value came from, for the warning text.
169
+ * @returns the normalized diff, or undefined.
170
+ */
171
+ function normalizeDiff(value, report, label) {
172
+ if (value === undefined || value === null) return undefined
173
+ if (typeof value !== 'object' || Array.isArray(value)) {
174
+ report(`${label} must be an object like { before, after }`)
175
+ return undefined
176
+ }
177
+ if (!isDiffSource(value.before) || !isDiffSource(value.after)) {
178
+ report(`${label}.before and ${label}.after must each be a parameter name or a function of the call`)
179
+ return undefined
180
+ }
181
+ if (value.path !== undefined && !isDiffSource(value.path)) {
182
+ report(`${label}.path must be a parameter name, a literal path, or a function of the call`)
183
+ return undefined
184
+ }
185
+ return {
186
+ before: value.before,
187
+ after: value.after,
188
+ ...(value.path === undefined ? {} : { path: value.path }),
189
+ ...(typeof value.title === 'string' ? { title: value.title } : {}),
190
+ }
191
+ }
192
+
153
193
  function normalizeField(entry, report, index) {
154
194
  if (typeof entry === 'string') return { param: entry }
155
195
  if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
@@ -164,6 +204,13 @@ function normalizeField(entry, report, index) {
164
204
  report(`fields[${index}].render must be one of ${RENDER_MODES.join(', ')}`)
165
205
  return undefined
166
206
  }
207
+ // `value` overrides where the field's text comes from: a literal, or a
208
+ // function of the pending call that the host resolves before the panel opens.
209
+ if (entry.value !== undefined && typeof entry.value !== 'string' && typeof entry.value !== 'function') {
210
+ report(`fields[${index}].value must be a literal string or a function of the call`)
211
+ return undefined
212
+ }
213
+ const diff = normalizeDiff(entry.diff, report, `fields[${index}].diff`)
167
214
  return {
168
215
  param: entry.param,
169
216
  ...(typeof entry.title === 'string' ? { title: entry.title } : {}),
@@ -171,7 +218,8 @@ function normalizeField(entry, report, index) {
171
218
  ...(entry.render === undefined ? {} : { render: entry.render }),
172
219
  ...(entry.editable === undefined ? {} : { editable: entry.editable === true }),
173
220
  ...(Array.isArray(entry.labels) ? { labels: entry.labels.filter(label => typeof label === 'string') } : {}),
174
- ...(entry.diff === undefined ? {} : { diff: entry.diff }),
221
+ ...(entry.value === undefined ? {} : { value: entry.value }),
222
+ ...(diff === undefined ? {} : { diff }),
175
223
  }
176
224
  }
177
225
 
@@ -213,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: normalizeCountdown(input.countdown, report),
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: WHEN_UNAVAILABLE.includes(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.1.1",
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",
@@ -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('shows every screenshot, and only its own language', () => {
66
- const referenced = new Set()
67
- const available = new Set(readdirSync(join(root, 'docs')))
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 images = [...read(readme.file).matchAll(/!\[[^\]]*\]\((docs\/[^)]+)\)/g)].map(match => match[1])
70
- assert.equal(images.length, 4, `${readme.file} must place all four screenshots`)
71
- for (const image of images) {
72
- const name = image.slice('docs/'.length)
73
- assert.equal(available.has(name), true, `${readme.file} references a missing screenshot: ${image}`)
74
- assert.equal(name.endsWith(`${readme.suffix}.png`), true, `${readme.file} must use its ${readme.suffix} screenshots`)
75
- referenced.add(name)
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 folder shipped', () => {
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
  })
@@ -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
+ })