dsh-scout 0.9.0 → 0.9.2
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/CHANGELOG.md +91 -0
- package/README.en.md +237 -39
- package/README.md +131 -26
- package/assets/icon.svg +7 -0
- package/dist/i18n.d.ts +115 -0
- package/dist/i18n.js +299 -0
- package/dist/index.d.ts +14 -9
- package/dist/index.js +682 -206
- package/dist/model.d.ts +58 -8
- package/dist/model.js +260 -107
- package/docs/dsh-scout-product-contract.md +51 -5
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +15 -7
- package/scripts/check-release.mjs +219 -4
- package/scripts/profile-smoke.mjs +1118 -41
- package/tests/acceptance.test.mjs +1684 -0
- package/tests/model.test.mjs +496 -778
- package/tests/package.test.mjs +246 -0
- package/tests/tools.test.mjs +974 -0
|
@@ -0,0 +1,1684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-scout 0.9.1 — independent acceptance suite (task-6).
|
|
3
|
+
*
|
|
4
|
+
* Owned by the independent verifier, not by any implementer. It deliberately
|
|
5
|
+
* does NOT re-use the assertions from `tests/tools.test.mjs` / `tests/model.test.mjs`;
|
|
6
|
+
* it drives the frozen build through its own Cordis-shaped harness and takes
|
|
7
|
+
* angles those suites do not cover:
|
|
8
|
+
*
|
|
9
|
+
* 1. `output.schema` is a *compiled native* JSON Schema, and the values of all
|
|
10
|
+
* twelve tools are validated against their own schema, with tamper controls
|
|
11
|
+
* proving the validator is live rather than vacuous;
|
|
12
|
+
* 2. per-class render budgets, including the adversarial inputs that used to
|
|
13
|
+
* break them (the whole-render UTF-8 cap is asserted, not just observed);
|
|
14
|
+
* 3. torn writes: the marker protocol (downgrade first, close last), what an
|
|
15
|
+
* interrupted write leaves on disk, and how `scout_import` and lazy
|
|
16
|
+
* rehydration each react — including the marker-vs-missing-file matrix;
|
|
17
|
+
* 4. the *content* of `export.complete.json` (`complete`, `eventCount`, file
|
|
18
|
+
* list, case id) versus `events.jsonl` and `report.md`, not its existence;
|
|
19
|
+
* 5. in-memory versus on-disk event counts under `autoPersist`, and three
|
|
20
|
+
* consecutive exports (complete → interrupted → complete);
|
|
21
|
+
* 6. call-site service binding, provider replacement, cwd/signal forwarding
|
|
22
|
+
* and disposal;
|
|
23
|
+
* 7. lazy rehydration from `<scoutDir>/<caseId>/`, including the case-id guard
|
|
24
|
+
* that stops an export of another case being adopted;
|
|
25
|
+
* 8. `language: 'en'` end to end (including the persisted `report.md`) and the
|
|
26
|
+
* unchanged `zh-CN` byte shape.
|
|
27
|
+
*
|
|
28
|
+
* Round 1 of this suite pinned six defects as *characterization* tests; round 2
|
|
29
|
+
* pinned a seventh as a *documented boundary* (`D10`). All seven were fixed and
|
|
30
|
+
* are now asserted as the positive contract; the names say which defect each one
|
|
31
|
+
* guards:
|
|
32
|
+
*
|
|
33
|
+
* - G1 (lazy rehydration ignored the completion marker) → marker is now required
|
|
34
|
+
* - G2 (the 1 KB render budget was not enforced) → per-field + whole-render caps
|
|
35
|
+
* - G3 (a failed overwrite left a stale `complete:true`) → marker is downgraded first
|
|
36
|
+
* - D1 (persisted report.md followed the case language) → export follows the render language
|
|
37
|
+
* - D7 (ingest drafts hard-coded Chinese notes) → both strings moved into i18n
|
|
38
|
+
* - D8 (stored factory prose kept the creation language) → untouched defaults align
|
|
39
|
+
* - D10 (a partially edited question set froze its untouched factory entries)
|
|
40
|
+
* → questions align entry by entry
|
|
41
|
+
*
|
|
42
|
+
* No test in this file asserts that a defect exists; §10b carries the seven-row
|
|
43
|
+
* behaviour matrix for the entry-by-entry rule. No real assertion is relaxed
|
|
44
|
+
* anywhere in this file.
|
|
45
|
+
*/
|
|
46
|
+
import test from 'node:test'
|
|
47
|
+
import assert from 'node:assert/strict'
|
|
48
|
+
import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
|
|
49
|
+
import { Config, apply } from '../dist/index.js'
|
|
50
|
+
import { CLAIM_DIMENSIONS, EXPORT_FILE_NAMES, scoutStrings } from '../dist/model.js'
|
|
51
|
+
|
|
52
|
+
/* ------------------------------------------------------------------ *
|
|
53
|
+
* Harness
|
|
54
|
+
* ------------------------------------------------------------------ */
|
|
55
|
+
|
|
56
|
+
const ALL_TOOLS = [
|
|
57
|
+
'scout_add_claim',
|
|
58
|
+
'scout_add_source',
|
|
59
|
+
'scout_compare',
|
|
60
|
+
'scout_export',
|
|
61
|
+
'scout_import',
|
|
62
|
+
'scout_ingest',
|
|
63
|
+
'scout_questions',
|
|
64
|
+
'scout_report',
|
|
65
|
+
'scout_search',
|
|
66
|
+
'scout_start',
|
|
67
|
+
'scout_verify_claim',
|
|
68
|
+
'scout_verify_identity',
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/** Mutation-envelope tools: they share one `output.schema` and one renderer. */
|
|
72
|
+
const ENVELOPE_TOOLS = [
|
|
73
|
+
'scout_add_claim',
|
|
74
|
+
'scout_add_source',
|
|
75
|
+
'scout_import',
|
|
76
|
+
'scout_ingest',
|
|
77
|
+
'scout_search',
|
|
78
|
+
'scout_start',
|
|
79
|
+
'scout_verify_claim',
|
|
80
|
+
'scout_verify_identity',
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const EXPORT_MARKER_FILE = 'export.complete.json'
|
|
84
|
+
/** Model-facing byte budget the README states for a compact render. */
|
|
85
|
+
const RENDER_BUDGET_BYTES = 1024
|
|
86
|
+
const CJK = /[\u4e00-\u9fff]/
|
|
87
|
+
const countCjk = text => (text.match(/[\u4e00-\u9fff]/gu) ?? []).length
|
|
88
|
+
|
|
89
|
+
const execFor = (sessionId = 'verifier', cwd = '/session/cwd') => ({
|
|
90
|
+
agent: { id: sessionId, session: { header: { cwd } } },
|
|
91
|
+
signal: new AbortController().signal,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Mount the plugin on a minimal Cordis-shaped context.
|
|
96
|
+
*
|
|
97
|
+
* `effect()` mirrors Cordis' contract for the two shapes this plugin uses: the
|
|
98
|
+
* tool registration is a *generator* yielding disposers, while the optional
|
|
99
|
+
* service bindings are plain functions returning one disposer. `inject()`
|
|
100
|
+
* defers until every dependency is mounted, so a service attached after
|
|
101
|
+
* `apply()` still reaches its registration callback.
|
|
102
|
+
*/
|
|
103
|
+
function createHarness({ config, services = {} } = {}) {
|
|
104
|
+
const tools = new Map()
|
|
105
|
+
const disposers = []
|
|
106
|
+
const injections = []
|
|
107
|
+
|
|
108
|
+
const ctx = {
|
|
109
|
+
get(serviceName) {
|
|
110
|
+
return this[serviceName]
|
|
111
|
+
},
|
|
112
|
+
tools: {
|
|
113
|
+
register(tool) {
|
|
114
|
+
tools.set(tool.name, tool)
|
|
115
|
+
return () => tools.delete(tool.name)
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
effect(callback) {
|
|
119
|
+
const produced = callback()
|
|
120
|
+
if (typeof produced === 'function') {
|
|
121
|
+
disposers.push(produced)
|
|
122
|
+
} else if (produced && typeof produced[Symbol.iterator] === 'function') {
|
|
123
|
+
for (const item of produced) if (typeof item === 'function') disposers.push(item)
|
|
124
|
+
}
|
|
125
|
+
return () => {}
|
|
126
|
+
},
|
|
127
|
+
inject(dependencies, callback) {
|
|
128
|
+
const entry = { dependencies: [...dependencies], callback, flushed: false }
|
|
129
|
+
injections.push(entry)
|
|
130
|
+
flushInjections()
|
|
131
|
+
},
|
|
132
|
+
/** Attach a host service, then re-run every deferred injection. */
|
|
133
|
+
mount(serviceName, service) {
|
|
134
|
+
this[serviceName] = service
|
|
135
|
+
flushInjections()
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function flushInjections() {
|
|
140
|
+
for (const entry of injections) {
|
|
141
|
+
if (entry.flushed) continue
|
|
142
|
+
if (!entry.dependencies.every(name => ctx[name])) continue
|
|
143
|
+
entry.flushed = true
|
|
144
|
+
entry.callback(ctx)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
Object.assign(ctx, services)
|
|
149
|
+
apply(ctx, config)
|
|
150
|
+
flushInjections()
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
ctx,
|
|
154
|
+
tools,
|
|
155
|
+
injections,
|
|
156
|
+
get disposerCount() {
|
|
157
|
+
return disposers.length
|
|
158
|
+
},
|
|
159
|
+
dispose() {
|
|
160
|
+
while (disposers.length) disposers.pop()()
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** In-memory filesystem double that can fail on one named file. */
|
|
166
|
+
class MemoryFs {
|
|
167
|
+
constructor({ failOn = null } = {}) {
|
|
168
|
+
this.files = new Map()
|
|
169
|
+
this.calls = []
|
|
170
|
+
this.failOn = failOn
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async resolve(path, options) {
|
|
174
|
+
this.calls.push({ op: 'resolve', path, options })
|
|
175
|
+
return { targetKey: `key:${path}`, displayPath: path }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async readText(target, signal) {
|
|
179
|
+
this.calls.push({ op: 'readText', path: target.displayPath, signal })
|
|
180
|
+
if (!this.files.has(target.displayPath)) {
|
|
181
|
+
throw new Error(`ENOENT: no such file: ${target.displayPath}`)
|
|
182
|
+
}
|
|
183
|
+
return this.files.get(target.displayPath)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async writeText(target, content, intent, signal) {
|
|
187
|
+
this.calls.push({ op: 'writeText', path: target.displayPath, content, intent, signal })
|
|
188
|
+
const baseName = target.displayPath.slice(target.displayPath.lastIndexOf('/') + 1)
|
|
189
|
+
if (this.failOn === baseName) {
|
|
190
|
+
throw new Error(`simulated write failure on ${baseName}`)
|
|
191
|
+
}
|
|
192
|
+
this.files.set(target.displayPath, content)
|
|
193
|
+
return { operation: 'create' }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
read(path) {
|
|
197
|
+
return this.files.get(path)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
has(path) {
|
|
201
|
+
return this.files.has(path)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
list() {
|
|
205
|
+
return [...this.files.keys()]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Basenames written, in call order — the completion marker appears twice. */
|
|
209
|
+
get writeOrder() {
|
|
210
|
+
return this.calls
|
|
211
|
+
.filter(entry => entry.op === 'writeText')
|
|
212
|
+
.map(entry => entry.path.slice(entry.path.lastIndexOf('/') + 1))
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function webStub(urls) {
|
|
217
|
+
return {
|
|
218
|
+
seen: [],
|
|
219
|
+
async search(request) {
|
|
220
|
+
this.seen.push(request)
|
|
221
|
+
return { sources: urls.map(url => ({ url, title: `Result ${url}` })), truncated: false }
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const call = (harness, name, args, exec) => harness.tools.get(name).execute(args, exec)
|
|
227
|
+
|
|
228
|
+
const renderBlocks = (harness, name, value) => harness.tools.get(name).output.render({}, value)
|
|
229
|
+
const renderText = blocks =>
|
|
230
|
+
blocks.filter(block => block.type === 'text').map(block => block.text).join('\n')
|
|
231
|
+
const renderBytes = blocks => Buffer.byteLength(renderText(blocks), 'utf8')
|
|
232
|
+
const eventTypes = jsonl => (jsonl ?? '').trim()
|
|
233
|
+
? jsonl.trim().split('\n').map(line => JSON.parse(line).type)
|
|
234
|
+
: []
|
|
235
|
+
const parseJsonl = jsonl => (jsonl ?? '').trim()
|
|
236
|
+
? jsonl.trim().split('\n').map(line => JSON.parse(line))
|
|
237
|
+
: []
|
|
238
|
+
|
|
239
|
+
/** Seed a case with twelve sourced claims, the realistic "loaded" shape. */
|
|
240
|
+
async function seedLoadedCase(harness, caseId, exec) {
|
|
241
|
+
await call(harness, 'scout_start', {
|
|
242
|
+
caseId,
|
|
243
|
+
companyName: 'Example Co',
|
|
244
|
+
roleTitle: 'HR Head',
|
|
245
|
+
location: 'Shenzhen',
|
|
246
|
+
}, exec)
|
|
247
|
+
const items = []
|
|
248
|
+
for (let index = 1; index <= 12; index += 1) {
|
|
249
|
+
items.push({
|
|
250
|
+
url: `https://example.com/report-${index}`,
|
|
251
|
+
title: `Independent report ${index}`,
|
|
252
|
+
claim: {
|
|
253
|
+
text: `Finding ${index} about the company and the role.`,
|
|
254
|
+
dimension: index % 2 ? 'risk' : 'mandate',
|
|
255
|
+
impact: index % 3 ? 'material' : 'blocking',
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
await call(harness, 'scout_ingest', { caseId, itemsJson: JSON.stringify(items) }, exec)
|
|
260
|
+
return exec
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* ------------------------------------------------------------------ *
|
|
264
|
+
* 1. Output schema is a real, compiled, enforced contract (12/12)
|
|
265
|
+
* ------------------------------------------------------------------ */
|
|
266
|
+
|
|
267
|
+
test('all twelve tools publish a compiled JSON Schema that their own returned values satisfy', async () => {
|
|
268
|
+
const fs = new MemoryFs()
|
|
269
|
+
const harness = createHarness({ services: { fs, web: webStub(['https://example.com/found']) } })
|
|
270
|
+
assert.deepEqual([...harness.tools.keys()].sort(), ALL_TOOLS)
|
|
271
|
+
|
|
272
|
+
for (const [name, tool] of harness.tools) {
|
|
273
|
+
// `assertSupportedJsonSchema` rejects the *spec* form (`required: true`
|
|
274
|
+
// inside a property). Passing here means defineTool really compiled it.
|
|
275
|
+
assert.doesNotThrow(() => assertSupportedJsonSchema(tool.output.schema), `${name} output schema`)
|
|
276
|
+
assert.equal(typeof tool.output.render, 'function', `${name} must render`)
|
|
277
|
+
assert.ok(
|
|
278
|
+
!/"required":(true|false)/.test(JSON.stringify(tool.output.schema)),
|
|
279
|
+
`${name} still exposes the uncompiled spec form of \`required\``,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// One call per tool, in a sequence that is valid under the evidence policy.
|
|
284
|
+
const exec = execFor()
|
|
285
|
+
const values = new Map()
|
|
286
|
+
values.set('scout_start', await call(harness, 'scout_start', {
|
|
287
|
+
caseId: 'a', companyName: 'Alpha Co', roleTitle: 'HR Head', location: '',
|
|
288
|
+
}, exec))
|
|
289
|
+
assert.equal((await call(harness, 'scout_start', {
|
|
290
|
+
caseId: 'b', companyName: 'Beta Co', roleTitle: 'CTO', location: '',
|
|
291
|
+
}, exec)).ok, true)
|
|
292
|
+
values.set('scout_add_source', await call(harness, 'scout_add_source', {
|
|
293
|
+
caseId: 'a', sourceId: 'media', sourceType: 'independent_media',
|
|
294
|
+
title: 'Media', url: 'https://example.com/media', evidenceLevel: 'E2',
|
|
295
|
+
}, exec))
|
|
296
|
+
assert.equal((await call(harness, 'scout_add_source', {
|
|
297
|
+
caseId: 'a', sourceId: 'registry', sourceType: 'company_registry', title: 'Registry',
|
|
298
|
+
url: 'https://www.gsxt.gov.cn/co/1', evidenceLevel: 'E3',
|
|
299
|
+
}, exec)).ok, true)
|
|
300
|
+
values.set('scout_add_claim', await call(harness, 'scout_add_claim', {
|
|
301
|
+
caseId: 'a', claimId: 'c1', text: 'The role exists.', status: 'reported', evidenceLevel: 'E2',
|
|
302
|
+
dimension: 'role_existence', impact: 'blocking', sourceIds: 'media',
|
|
303
|
+
confidenceNote: 'Independent media.', nextAction: 'Confirm the mandate.',
|
|
304
|
+
}, exec))
|
|
305
|
+
values.set('scout_ingest', await call(harness, 'scout_ingest', {
|
|
306
|
+
caseId: 'a', itemsJson: JSON.stringify([{ url: 'https://example.com/extra', title: 'Extra' }]),
|
|
307
|
+
}, exec))
|
|
308
|
+
values.set('scout_search', await call(harness, 'scout_search', { caseId: 'a', query: 'alpha' }, exec))
|
|
309
|
+
values.set('scout_verify_identity', await call(harness, 'scout_verify_identity', {
|
|
310
|
+
caseId: 'a', legalEntity: 'Alpha Co Ltd', registrationNumber: '91440300X',
|
|
311
|
+
registeredRegion: 'Shenzhen', legalRepresentative: 'Zhang San',
|
|
312
|
+
brandRelationship: 'Operating entity', sourceIds: 'registry',
|
|
313
|
+
}, exec))
|
|
314
|
+
values.set('scout_verify_claim', await call(harness, 'scout_verify_claim', {
|
|
315
|
+
caseId: 'a', claimId: 'c1', evidenceLevel: 'E2', sourceIds: 'media,registry',
|
|
316
|
+
confidenceNote: 'Two sources.', nextAction: 'Keep.',
|
|
317
|
+
}, exec))
|
|
318
|
+
values.set('scout_report', await call(harness, 'scout_report', { caseId: 'a' }, exec))
|
|
319
|
+
values.set('scout_questions', await call(harness, 'scout_questions', { caseId: 'a' }, exec))
|
|
320
|
+
values.set('scout_compare', await call(harness, 'scout_compare', { caseIds: 'a,b' }, exec))
|
|
321
|
+
values.set('scout_export', await call(harness, 'scout_export', { caseId: 'a', targetDir: '/export/a' }, exec))
|
|
322
|
+
values.set('scout_import', await call(harness, 'scout_import', { caseId: 'a', sourceDir: '/export/a' }, exec))
|
|
323
|
+
|
|
324
|
+
assert.deepEqual([...values.keys()].sort(), ALL_TOOLS, 'every tool must be exercised exactly once')
|
|
325
|
+
for (const [name, value] of values) {
|
|
326
|
+
const schema = harness.tools.get(name).output.schema
|
|
327
|
+
assert.deepEqual(
|
|
328
|
+
validateJsonSchemaValue(schema, value),
|
|
329
|
+
[],
|
|
330
|
+
`${name} returned a value that violates its own declared output schema`,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Tamper controls: without these, the block above would also pass on a
|
|
335
|
+
// validator that never reports anything.
|
|
336
|
+
for (const [name, value] of values) {
|
|
337
|
+
const schema = harness.tools.get(name).output.schema
|
|
338
|
+
assert.ok(
|
|
339
|
+
validateJsonSchemaValue(schema, {}).length > 0,
|
|
340
|
+
`${name} schema must require its properties (an empty object was accepted)`,
|
|
341
|
+
)
|
|
342
|
+
assert.ok(
|
|
343
|
+
validateJsonSchemaValue(schema, { ...value, __unexpected: true }).length > 0,
|
|
344
|
+
`${name} schema must reject an undeclared property`,
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
assert.ok(
|
|
348
|
+
validateJsonSchemaValue(harness.tools.get('scout_report').output.schema, {
|
|
349
|
+
...values.get('scout_report'),
|
|
350
|
+
decision: 'MAYBE',
|
|
351
|
+
}).length > 0,
|
|
352
|
+
'scout_report must reject a decision outside the closed enum',
|
|
353
|
+
)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
/* ------------------------------------------------------------------ *
|
|
357
|
+
* 2. Render budgets
|
|
358
|
+
* ------------------------------------------------------------------ */
|
|
359
|
+
|
|
360
|
+
test('mutating tools render a compact summary while report and compare stay the full artifact', async () => {
|
|
361
|
+
const harness = createHarness({ services: { fs: new MemoryFs() } })
|
|
362
|
+
const exec = await seedLoadedCase(harness, 'budget', execFor())
|
|
363
|
+
|
|
364
|
+
const compact = new Map()
|
|
365
|
+
compact.set('scout_ingest', await call(harness, 'scout_ingest', {
|
|
366
|
+
caseId: 'budget', itemsJson: JSON.stringify([{ url: 'https://example.com/late', title: 'Late' }]),
|
|
367
|
+
}, exec))
|
|
368
|
+
compact.set('scout_add_source', await call(harness, 'scout_add_source', {
|
|
369
|
+
caseId: 'budget', sourceId: 'late-src', sourceType: 'other', title: 'Late',
|
|
370
|
+
url: 'https://example.com/late-2', evidenceLevel: 'E2',
|
|
371
|
+
}, exec))
|
|
372
|
+
compact.set('scout_add_claim', await call(harness, 'scout_add_claim', {
|
|
373
|
+
caseId: 'budget', claimId: 'late-claim', text: 'Late claim.', status: 'reported', evidenceLevel: 'E2',
|
|
374
|
+
dimension: 'risk', impact: 'material', sourceIds: 'late-src',
|
|
375
|
+
confidenceNote: 'Late.', nextAction: 'Check it.',
|
|
376
|
+
}, exec))
|
|
377
|
+
compact.set('scout_start', await call(harness, 'scout_start', {
|
|
378
|
+
caseId: 'budget-2', companyName: 'Second Co', roleTitle: 'CTO', location: '',
|
|
379
|
+
}, exec))
|
|
380
|
+
compact.set('scout_questions', await call(harness, 'scout_questions', { caseId: 'budget' }, exec))
|
|
381
|
+
compact.set('scout_export', await call(harness, 'scout_export', {
|
|
382
|
+
caseId: 'budget', targetDir: '/export/budget',
|
|
383
|
+
}, exec))
|
|
384
|
+
compact.set('scout_import', await call(harness, 'scout_import', {
|
|
385
|
+
caseId: 'budget', sourceDir: '/export/budget',
|
|
386
|
+
}, exec))
|
|
387
|
+
|
|
388
|
+
for (const [name, value] of compact) {
|
|
389
|
+
const blocks = renderBlocks(harness, name, value)
|
|
390
|
+
assert.ok(Array.isArray(blocks) && blocks.length > 0, `${name} render must emit content`)
|
|
391
|
+
assert.ok(blocks.every(block => block.type === 'text'), `${name} render blocks must be text`)
|
|
392
|
+
const bytes = renderBytes(blocks)
|
|
393
|
+
assert.ok(bytes < RENDER_BUDGET_BYTES, `${name} render was ${bytes} bytes (budget ${RENDER_BUDGET_BYTES})`)
|
|
394
|
+
assert.doesNotMatch(renderText(blocks), /"schemaVersion"/, `${name} must not dump the raw case JSON`)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// The two documented full artifacts are the only renders allowed past 1 KB.
|
|
398
|
+
const reportBlocks = renderBlocks(harness, 'scout_report', await call(harness, 'scout_report', { caseId: 'budget' }, exec))
|
|
399
|
+
assert.ok(renderBytes(reportBlocks) > RENDER_BUDGET_BYTES, 'scout_report carries the full report')
|
|
400
|
+
const reportText = renderText(reportBlocks)
|
|
401
|
+
assert.match(reportText, /^# Example Co — HR Head$/m)
|
|
402
|
+
for (const heading of [
|
|
403
|
+
'## Coverage',
|
|
404
|
+
'## Key supporting evidence (up to 3)',
|
|
405
|
+
'## Blocking or unresolved risks (up to 3)',
|
|
406
|
+
'## Role task and authority hypotheses',
|
|
407
|
+
'## Verification checklist',
|
|
408
|
+
'## Claim ledger',
|
|
409
|
+
'## Sources',
|
|
410
|
+
'## Interview questions',
|
|
411
|
+
]) {
|
|
412
|
+
assert.ok(reportText.includes(heading), `report is missing ${heading}`)
|
|
413
|
+
}
|
|
414
|
+
assert.match(reportText, /https:\/\/example\.com\/report-1/, 'report must carry source URLs')
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
test('the 1 KB render budget holds even against adversarial caller-supplied text', async () => {
|
|
418
|
+
// Round 1 pinned the opposite: a 3000-character `nextAction` produced a
|
|
419
|
+
// 3121-byte render because only `changed.ids` was capped. Round 2 added
|
|
420
|
+
// per-field clipping plus a whole-render UTF-8 hard cap, so this test now
|
|
421
|
+
// asserts the budget itself — for ASCII, for multi-byte text, and for a
|
|
422
|
+
// synthetic envelope with every field at maximum size.
|
|
423
|
+
const harness = createHarness({ services: { fs: new MemoryFs() } })
|
|
424
|
+
const exec = execFor()
|
|
425
|
+
await call(harness, 'scout_start', { caseId: 'long', companyName: 'Long Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
426
|
+
await call(harness, 'scout_add_source', {
|
|
427
|
+
caseId: 'long', sourceId: 'src', sourceType: 'other', title: 'Source',
|
|
428
|
+
url: 'https://example.com/src', evidenceLevel: 'E2',
|
|
429
|
+
}, exec)
|
|
430
|
+
|
|
431
|
+
const ascii = 'X'.repeat(3000)
|
|
432
|
+
const asciiValue = await call(harness, 'scout_add_claim', {
|
|
433
|
+
caseId: 'long', claimId: 'c1', text: 'Short text.', status: 'reported', evidenceLevel: 'E2',
|
|
434
|
+
dimension: 'risk', impact: 'blocking', sourceIds: 'src',
|
|
435
|
+
confidenceNote: 'Note.', nextAction: ascii,
|
|
436
|
+
}, exec)
|
|
437
|
+
const asciiBytes = renderBytes(renderBlocks(harness, 'scout_add_claim', asciiValue))
|
|
438
|
+
assert.ok(asciiBytes <= RENDER_BUDGET_BYTES, `ASCII render was ${asciiBytes} bytes`)
|
|
439
|
+
// Only the render is capped: the structured value still carries the caller's
|
|
440
|
+
// full text, so nothing is silently lost from the machine-readable field.
|
|
441
|
+
assert.equal(asciiValue.nextActions[0], ascii, 'the value keeps the full nextAction')
|
|
442
|
+
|
|
443
|
+
const cjk = '中'.repeat(3000)
|
|
444
|
+
const cjkValue = await call(harness, 'scout_add_claim', {
|
|
445
|
+
caseId: 'long', claimId: 'c2', text: '多字节文本。', status: 'reported', evidenceLevel: 'E2',
|
|
446
|
+
dimension: 'risk', impact: 'material', sourceIds: 'src',
|
|
447
|
+
confidenceNote: '备注。', nextAction: cjk,
|
|
448
|
+
}, exec)
|
|
449
|
+
const cjkBytes = renderBytes(renderBlocks(harness, 'scout_add_claim', cjkValue))
|
|
450
|
+
assert.ok(cjkBytes <= RENDER_BUDGET_BYTES, `multi-byte render was ${cjkBytes} bytes`)
|
|
451
|
+
|
|
452
|
+
// Three maximal multi-byte next actions: the per-field caps alone would still
|
|
453
|
+
// exceed 1 KB, so the whole-render cap has to fire.
|
|
454
|
+
for (let index = 3; index <= 4; index += 1) {
|
|
455
|
+
await call(harness, 'scout_add_claim', {
|
|
456
|
+
caseId: 'long', claimId: `c${index}`, text: `多字节 ${index}`, status: 'reported', evidenceLevel: 'E2',
|
|
457
|
+
dimension: 'risk', impact: 'blocking', sourceIds: 'src',
|
|
458
|
+
confidenceNote: '备注。', nextAction: '中'.repeat(500),
|
|
459
|
+
}, exec)
|
|
460
|
+
}
|
|
461
|
+
const synthetic = {
|
|
462
|
+
ok: true,
|
|
463
|
+
caseId: '案'.repeat(200),
|
|
464
|
+
decision: 'VERIFY',
|
|
465
|
+
identity: 'needs_verification',
|
|
466
|
+
counts: { sources: 1, claims: 4, verified: 0, open: 4 },
|
|
467
|
+
changed: { kind: 'case_imported', ids: ['案'.repeat(60)] },
|
|
468
|
+
nextActions: ['中'.repeat(500), '中'.repeat(500), '中'.repeat(500)],
|
|
469
|
+
sourceDir: '/目'.repeat(200),
|
|
470
|
+
complete: false,
|
|
471
|
+
}
|
|
472
|
+
const syntheticBlocks = renderBlocks(harness, 'scout_import', synthetic)
|
|
473
|
+
const syntheticBytes = renderBytes(syntheticBlocks)
|
|
474
|
+
assert.ok(syntheticBytes <= RENDER_BUDGET_BYTES, `worst-case render was ${syntheticBytes} bytes`)
|
|
475
|
+
assert.ok(
|
|
476
|
+
renderText(syntheticBlocks).endsWith('…'),
|
|
477
|
+
'the whole-render cap must clip rather than silently drop trailing lines',
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
// F5: the clip budget is derived from the ellipsis it appends, so the clipped
|
|
481
|
+
// result — ellipsis included — lands *on* the budget rather than some magic
|
|
482
|
+
// number of bytes short of it. An ASCII-only envelope of the same shape is
|
|
483
|
+
// therefore exactly one budget wide, whichever way the clip falls.
|
|
484
|
+
const asciiSynthetic = {
|
|
485
|
+
ok: true,
|
|
486
|
+
caseId: 'A'.repeat(80),
|
|
487
|
+
decision: 'VERIFY',
|
|
488
|
+
identity: 'needs_verification',
|
|
489
|
+
counts: { sources: 1, claims: 4, verified: 0, open: 4 },
|
|
490
|
+
changed: { kind: 'case_imported', ids: ['B'.repeat(200)] },
|
|
491
|
+
nextActions: Array.from({ length: 6 }, () => 'C'.repeat(140)),
|
|
492
|
+
sourceDir: '/D'.repeat(160),
|
|
493
|
+
complete: false,
|
|
494
|
+
}
|
|
495
|
+
const asciiSyntheticBytes = renderBytes(renderBlocks(harness, 'scout_import', asciiSynthetic))
|
|
496
|
+
assert.equal(
|
|
497
|
+
asciiSyntheticBytes,
|
|
498
|
+
RENDER_BUDGET_BYTES,
|
|
499
|
+
'an ASCII clip must fill the whole budget, ellipsis bytes included',
|
|
500
|
+
)
|
|
501
|
+
assert.ok(renderText(renderBlocks(harness, 'scout_import', asciiSynthetic)).endsWith('…'))
|
|
502
|
+
|
|
503
|
+
// Emoji are four UTF-8 bytes carried by a surrogate pair, and the per-field
|
|
504
|
+
// clip slices UTF-16 code units, so it can stop between the halves of a pair.
|
|
505
|
+
// Every one of those renders must still fit the budget.
|
|
506
|
+
for (const copies of [2, 3, 4, 5, 6, 7]) {
|
|
507
|
+
const emojiEnvelope = {
|
|
508
|
+
...asciiSynthetic,
|
|
509
|
+
caseId: '🙂'.repeat(120),
|
|
510
|
+
changed: { kind: 'case_imported', ids: ['🚀'.repeat(120)] },
|
|
511
|
+
nextActions: Array.from({ length: copies }, () => '🧭'.repeat(300)),
|
|
512
|
+
sourceDir: `/🌍${'🙂'.repeat(200)}`,
|
|
513
|
+
}
|
|
514
|
+
const blocks = renderBlocks(harness, 'scout_import', emojiEnvelope)
|
|
515
|
+
const bytes = renderBytes(blocks)
|
|
516
|
+
assert.ok(bytes <= RENDER_BUDGET_BYTES, `${copies} emoji actions rendered ${bytes} bytes`)
|
|
517
|
+
assert.ok(renderText(blocks).endsWith('…'), `${copies} emoji actions must be clipped, not dropped`)
|
|
518
|
+
assert.ok(
|
|
519
|
+
bytes > RENDER_BUDGET_BYTES - 4,
|
|
520
|
+
`${copies} emoji actions rendered ${bytes} bytes: the clip must use the budget it has`,
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
/* ------------------------------------------------------------------ *
|
|
526
|
+
* 3. Declared parameters are enforced before execution
|
|
527
|
+
* ------------------------------------------------------------------ */
|
|
528
|
+
|
|
529
|
+
test('every tool rejects an empty argument object, so the declared parameters are not decorative', async () => {
|
|
530
|
+
const harness = createHarness({ services: { fs: new MemoryFs(), web: webStub([]) } })
|
|
531
|
+
assert.deepEqual([...harness.tools.keys()].sort(), ALL_TOOLS)
|
|
532
|
+
for (const name of ALL_TOOLS) {
|
|
533
|
+
await assert.rejects(
|
|
534
|
+
() => call(harness, name, {}, execFor()),
|
|
535
|
+
error => {
|
|
536
|
+
assert.equal(error.name, 'ToolArgsError', `${name} must fail argument validation, got ${error.name}`)
|
|
537
|
+
assert.match(error.message, /missing required property/, `${name} must name the missing property`)
|
|
538
|
+
return true
|
|
539
|
+
},
|
|
540
|
+
`${name} accepted an empty argument object`,
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
/* ------------------------------------------------------------------ *
|
|
546
|
+
* 4. autoPersist failure visibility
|
|
547
|
+
* ------------------------------------------------------------------ */
|
|
548
|
+
|
|
549
|
+
test('a failed autoPersist is visible in the return value and drops the case_exported event it could not write', async () => {
|
|
550
|
+
const fs = new MemoryFs()
|
|
551
|
+
const harness = createHarness({ config: { scoutDir: '/auto', autoPersist: true }, services: { fs } })
|
|
552
|
+
const exec = execFor()
|
|
553
|
+
await call(harness, 'scout_start', { caseId: 'auto', companyName: 'Auto Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
554
|
+
assert.deepEqual(eventTypes(fs.read('/auto/auto/events.jsonl')), ['case_started', 'case_exported'])
|
|
555
|
+
assert.equal(JSON.parse(fs.read('/auto/auto/export.complete.json')).eventCount, 2)
|
|
556
|
+
|
|
557
|
+
// Fail half-way through the next write: the event stream itself fails.
|
|
558
|
+
fs.failOn = 'events.jsonl'
|
|
559
|
+
const failed = await call(harness, 'scout_add_source', {
|
|
560
|
+
caseId: 'auto', sourceId: 'src', sourceType: 'other', title: 'Source',
|
|
561
|
+
url: 'https://example.com/src', evidenceLevel: 'E2',
|
|
562
|
+
}, exec)
|
|
563
|
+
|
|
564
|
+
assert.equal(failed.persist.persisted, false)
|
|
565
|
+
assert.equal(failed.persist.targetDir, '/auto/auto')
|
|
566
|
+
assert.match(failed.persist.error, /events\.jsonl/)
|
|
567
|
+
assert.match(renderText(renderBlocks(harness, 'scout_add_source', failed)), /PERSIST FAILED/)
|
|
568
|
+
|
|
569
|
+
// The event the failed write would have recorded is absent from disk: the
|
|
570
|
+
// directory still holds the previous generation's event stream, so it never
|
|
571
|
+
// claims a case_exported that did not happen.
|
|
572
|
+
const onDisk = eventTypes(fs.read('/auto/auto/events.jsonl'))
|
|
573
|
+
assert.deepEqual(onDisk, ['case_started', 'case_exported'], 'the failed write must not append an event')
|
|
574
|
+
assert.ok(!onDisk.includes('source_added'))
|
|
575
|
+
|
|
576
|
+
// The failed attempt left the directory explicitly invalid rather than
|
|
577
|
+
// apparently complete.
|
|
578
|
+
assert.equal(JSON.parse(fs.read('/auto/auto/export.complete.json')).complete, false)
|
|
579
|
+
|
|
580
|
+
// The in-memory case must be just as honest: the next successful export
|
|
581
|
+
// records exactly one more case_exported, not one per attempt.
|
|
582
|
+
fs.failOn = null
|
|
583
|
+
const healed = await call(harness, 'scout_export', { caseId: 'auto', targetDir: '/auto/healed' }, exec)
|
|
584
|
+
assert.equal(healed.persisted, true)
|
|
585
|
+
assert.deepEqual(eventTypes(fs.read('/auto/healed/events.jsonl')), [
|
|
586
|
+
'case_started', 'case_exported', 'source_added', 'case_exported',
|
|
587
|
+
])
|
|
588
|
+
const healedMarker = JSON.parse(fs.read('/auto/healed/export.complete.json'))
|
|
589
|
+
assert.equal(healedMarker.complete, true)
|
|
590
|
+
assert.equal(healedMarker.eventCount, 4)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test('an interrupted overwrite downgrades the completion marker instead of leaving a stale complete:true', async () => {
|
|
594
|
+
// Round 1 pinned the opposite: the marker was written last, so an interrupted
|
|
595
|
+
// *overwrite* of an existing export left the previous `complete:true` marker
|
|
596
|
+
// beside mixed-generation files and `scout_import` reported `complete: true`.
|
|
597
|
+
// Round 2 writes the marker as `complete:false` before touching the payload
|
|
598
|
+
// and only re-asserts `complete:true` after every data file landed, so this
|
|
599
|
+
// test now asserts the invalidation contract.
|
|
600
|
+
const fs = new MemoryFs()
|
|
601
|
+
const harness = createHarness({ config: { scoutDir: '/auto', autoPersist: true }, services: { fs } })
|
|
602
|
+
const exec = execFor()
|
|
603
|
+
await call(harness, 'scout_start', { caseId: 'gen', companyName: 'Gen Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
604
|
+
|
|
605
|
+
// Generation 1 is complete and self-consistent.
|
|
606
|
+
const firstMarker = JSON.parse(fs.read('/auto/gen/export.complete.json'))
|
|
607
|
+
assert.equal(firstMarker.complete, true)
|
|
608
|
+
assert.equal(firstMarker.eventCount, 2)
|
|
609
|
+
assert.equal(firstMarker.eventCount, eventTypes(fs.read('/auto/gen/events.jsonl')).length)
|
|
610
|
+
|
|
611
|
+
// Generation 2 fails on events.jsonl, after case.json/sources.json/claims.json
|
|
612
|
+
// were already overwritten.
|
|
613
|
+
fs.failOn = 'events.jsonl'
|
|
614
|
+
const failed = await call(harness, 'scout_add_claim', {
|
|
615
|
+
caseId: 'gen', claimId: 'c1', text: 'A claim.', status: 'needs_verification', evidenceLevel: 'E0',
|
|
616
|
+
dimension: 'risk', impact: 'material', sourceIds: '', confidenceNote: 'Note.', nextAction: 'Check.',
|
|
617
|
+
}, exec)
|
|
618
|
+
assert.equal(failed.persist.persisted, false)
|
|
619
|
+
|
|
620
|
+
// The marker is now explicitly invalid — that, and not its existence, is what
|
|
621
|
+
// a reader must trust. Its `eventCount` describes the attempt that failed, so
|
|
622
|
+
// it deliberately disagrees with the still-old `events.jsonl` on disk.
|
|
623
|
+
const tornMarker = JSON.parse(fs.read('/auto/gen/export.complete.json'))
|
|
624
|
+
const eventsOnDisk = eventTypes(fs.read('/auto/gen/events.jsonl'))
|
|
625
|
+
assert.equal(tornMarker.complete, false, 'an interrupted overwrite must downgrade the marker')
|
|
626
|
+
assert.equal(JSON.parse(fs.read('/auto/gen/claims.json')).length, 1, 'claims.json is from the new generation')
|
|
627
|
+
assert.ok(!eventsOnDisk.includes('claim_added'), 'events.jsonl still holds the previous generation')
|
|
628
|
+
assert.notEqual(tornMarker.eventCount, eventsOnDisk.length, 'the invalidated marker describes the failed attempt')
|
|
629
|
+
|
|
630
|
+
// A reader that respects the marker sees the directory as incomplete.
|
|
631
|
+
const imported = await call(harness, 'scout_import', { caseId: 'gen', sourceDir: '/auto/gen' }, exec)
|
|
632
|
+
assert.equal(imported.complete, false, 'a mixed-generation directory must not report complete:true')
|
|
633
|
+
const freshInstance = createHarness({ config: { scoutDir: '/auto' }, services: { fs } })
|
|
634
|
+
await assert.rejects(
|
|
635
|
+
() => call(freshInstance, 'scout_report', { caseId: 'gen' }, execFor()),
|
|
636
|
+
/Unknown case: gen/,
|
|
637
|
+
'rehydration must refuse an invalidated export',
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
// A later successful write restores agreement between the data and the marker.
|
|
641
|
+
fs.failOn = null
|
|
642
|
+
await call(harness, 'scout_export', { caseId: 'gen', targetDir: '/auto/gen' }, exec)
|
|
643
|
+
const healedMarker = JSON.parse(fs.read('/auto/gen/export.complete.json'))
|
|
644
|
+
assert.equal(healedMarker.complete, true)
|
|
645
|
+
assert.equal(healedMarker.eventCount, eventTypes(fs.read('/auto/gen/events.jsonl')).length)
|
|
646
|
+
assert.equal(healedMarker.eventCount, 4)
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
/* ------------------------------------------------------------------ *
|
|
650
|
+
* 5. Export marker content agreement
|
|
651
|
+
* ------------------------------------------------------------------ */
|
|
652
|
+
|
|
653
|
+
test('the export marker, events.jsonl, report.md and case.json agree with the case that was exported', async () => {
|
|
654
|
+
const fs = new MemoryFs()
|
|
655
|
+
const harness = createHarness({ services: { fs } })
|
|
656
|
+
const exec = await seedLoadedCase(harness, 'agree', execFor())
|
|
657
|
+
await call(harness, 'scout_verify_claim', {
|
|
658
|
+
caseId: 'agree', claimId: 'claim-1', evidenceLevel: 'E2', sourceIds: 'src-1',
|
|
659
|
+
confidenceNote: 'Verifier note.', nextAction: 'Next step.',
|
|
660
|
+
}, exec)
|
|
661
|
+
|
|
662
|
+
const exported = await call(harness, 'scout_export', { caseId: 'agree', targetDir: '/export/agree' }, exec)
|
|
663
|
+
assert.equal(exported.persisted, true)
|
|
664
|
+
assert.equal(exported.complete, true)
|
|
665
|
+
assert.deepEqual(exported.files, [...EXPORT_FILE_NAMES, EXPORT_MARKER_FILE])
|
|
666
|
+
for (const fileName of exported.files) {
|
|
667
|
+
assert.ok(fs.has(`/export/agree/${fileName}`), `export is missing ${fileName}`)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const events = parseJsonl(fs.read('/export/agree/events.jsonl'))
|
|
671
|
+
assert.equal(events.at(-1).type, 'case_exported', 'the event this export records must be the last line on disk')
|
|
672
|
+
assert.equal(events.at(-1).detail.targetDir, '/export/agree')
|
|
673
|
+
assert.equal(events.at(-1).detail.auto, false, 'an explicit export must be marked as not automatic')
|
|
674
|
+
|
|
675
|
+
const marker = JSON.parse(fs.read(`/export/agree/${EXPORT_MARKER_FILE}`))
|
|
676
|
+
assert.equal(marker.schemaVersion, 'dsh-scout.export.v1')
|
|
677
|
+
assert.equal(marker.caseId, 'agree')
|
|
678
|
+
assert.deepEqual(marker.files, [...EXPORT_FILE_NAMES], 'the marker lists the five data files, not itself')
|
|
679
|
+
assert.equal(marker.eventCount, events.length, 'the marker event count must match events.jsonl')
|
|
680
|
+
|
|
681
|
+
const caseJson = JSON.parse(fs.read('/export/agree/case.json'))
|
|
682
|
+
assert.equal(caseJson.caseId, 'agree')
|
|
683
|
+
assert.equal(caseJson.decision, 'VERIFY')
|
|
684
|
+
assert.ok(!('events' in caseJson), 'case.json must not duplicate the event stream')
|
|
685
|
+
const sourcesJson = JSON.parse(fs.read('/export/agree/sources.json'))
|
|
686
|
+
const claimsJson = JSON.parse(fs.read('/export/agree/claims.json'))
|
|
687
|
+
assert.equal(sourcesJson.length, 12)
|
|
688
|
+
assert.equal(claimsJson.length, 12)
|
|
689
|
+
// The files must describe the same case the envelope reported, not a stale one.
|
|
690
|
+
const envelopeCounts = (await call(harness, 'scout_import', {
|
|
691
|
+
caseId: 'agree', sourceDir: '/export/agree',
|
|
692
|
+
}, exec)).counts
|
|
693
|
+
assert.equal(envelopeCounts.sources, sourcesJson.length)
|
|
694
|
+
assert.equal(envelopeCounts.claims, claimsJson.length)
|
|
695
|
+
|
|
696
|
+
// The persisted report snapshot must be the same artifact the report tool
|
|
697
|
+
// returns — not a different rendering or a stale one.
|
|
698
|
+
const report = await call(harness, 'scout_report', { caseId: 'agree' }, exec)
|
|
699
|
+
assert.equal(fs.read('/export/agree/report.md'), report.report, 'report.md must equal the rendered report')
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
/* ------------------------------------------------------------------ *
|
|
703
|
+
* 6. Memory vs disk event counts, stable export/import/export sequence
|
|
704
|
+
* ------------------------------------------------------------------ */
|
|
705
|
+
|
|
706
|
+
test('autoPersist keeps the on-disk event stream equal to the in-memory event count', async () => {
|
|
707
|
+
const fs = new MemoryFs()
|
|
708
|
+
const harness = createHarness({ config: { scoutDir: '/auto', autoPersist: true }, services: { fs } })
|
|
709
|
+
const exec = execFor()
|
|
710
|
+
await call(harness, 'scout_start', { caseId: 'counts', companyName: 'Counts Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
711
|
+
await call(harness, 'scout_add_source', {
|
|
712
|
+
caseId: 'counts', sourceId: 'src', sourceType: 'other', title: 'Source',
|
|
713
|
+
url: 'https://example.com/src', evidenceLevel: 'E2',
|
|
714
|
+
}, exec)
|
|
715
|
+
await call(harness, 'scout_add_claim', {
|
|
716
|
+
caseId: 'counts', claimId: 'c1', text: 'A claim.', status: 'reported', evidenceLevel: 'E2',
|
|
717
|
+
dimension: 'risk', impact: 'material', sourceIds: 'src', confidenceNote: 'Note.', nextAction: 'Check.',
|
|
718
|
+
}, exec)
|
|
719
|
+
|
|
720
|
+
const lines = eventTypes(fs.read('/auto/counts/events.jsonl'))
|
|
721
|
+
const marker = JSON.parse(fs.read('/auto/counts/export.complete.json'))
|
|
722
|
+
assert.deepEqual(lines, [
|
|
723
|
+
'case_started', 'case_exported', 'source_added', 'case_exported', 'claim_added', 'case_exported',
|
|
724
|
+
])
|
|
725
|
+
assert.equal(marker.eventCount, lines.length, 'in-memory event count and events.jsonl line count must agree')
|
|
726
|
+
|
|
727
|
+
// Export -> import -> export is append-only and does not renumber or repeat.
|
|
728
|
+
await call(harness, 'scout_import', { caseId: 'counts', sourceDir: '/auto/counts' }, exec)
|
|
729
|
+
await call(harness, 'scout_export', { caseId: 'counts', targetDir: '/auto/again' }, exec)
|
|
730
|
+
const after = eventTypes(fs.read('/auto/again/events.jsonl'))
|
|
731
|
+
assert.deepEqual(after.slice(0, lines.length), lines, 'the earlier stream must be preserved in order')
|
|
732
|
+
assert.deepEqual(after.slice(lines.length), ['case_imported', 'case_exported'])
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
/* ------------------------------------------------------------------ *
|
|
736
|
+
* 7. Torn exports
|
|
737
|
+
* ------------------------------------------------------------------ */
|
|
738
|
+
|
|
739
|
+
test('a write that fails part-way leaves a torn export that is rejected or honestly marked incomplete', async () => {
|
|
740
|
+
// (a) Interrupted on the third `writeText` call (case.json and sources.json
|
|
741
|
+
// have landed, claims.json fails): the directory is unusable and
|
|
742
|
+
// `scout_import` must say so instead of returning a partial case.
|
|
743
|
+
const earlyFs = new MemoryFs({ failOn: 'claims.json' })
|
|
744
|
+
const early = createHarness({ config: { scoutDir: '/scout' }, services: { fs: earlyFs } })
|
|
745
|
+
const exec = execFor()
|
|
746
|
+
await call(early, 'scout_start', { caseId: 'torn-early', companyName: 'Torn Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
747
|
+
const earlyExport = await call(early, 'scout_export', { caseId: 'torn-early', targetDir: '/scout/torn-early' }, exec)
|
|
748
|
+
assert.equal(earlyExport.persisted, false)
|
|
749
|
+
assert.equal(earlyExport.complete, false)
|
|
750
|
+
assert.match(earlyExport.error, /claims\.json/)
|
|
751
|
+
// Round 2 writes the marker *first*, as `complete: false`, and only re-asserts
|
|
752
|
+
// it after the payload landed — so the interrupted directory now carries an
|
|
753
|
+
// explicitly invalid marker rather than no marker at all.
|
|
754
|
+
assert.deepEqual(earlyFs.list().sort(), [
|
|
755
|
+
'/scout/torn-early/case.json',
|
|
756
|
+
'/scout/torn-early/export.complete.json',
|
|
757
|
+
'/scout/torn-early/sources.json',
|
|
758
|
+
])
|
|
759
|
+
const earlyMarker = JSON.parse(earlyFs.read('/scout/torn-early/export.complete.json'))
|
|
760
|
+
assert.equal(earlyMarker.complete, false)
|
|
761
|
+
assert.equal(earlyMarker.caseId, 'torn-early')
|
|
762
|
+
assert.ok(!earlyFs.has('/scout/torn-early/claims.json'))
|
|
763
|
+
// The write order itself is the guarantee: marker(false) first, marker(true) last.
|
|
764
|
+
assert.equal(earlyFs.writeOrder[0], EXPORT_MARKER_FILE)
|
|
765
|
+
assert.equal(earlyFs.writeOrder.at(-1), 'claims.json', 'the failed run never reached the closing marker write')
|
|
766
|
+
await assert.rejects(
|
|
767
|
+
() => call(early, 'scout_import', { caseId: 'torn-early', sourceDir: '/scout/torn-early' }, exec),
|
|
768
|
+
error => {
|
|
769
|
+
assert.match(error.message, /claims\.json/, 'the rejection must name the missing file')
|
|
770
|
+
return true
|
|
771
|
+
},
|
|
772
|
+
)
|
|
773
|
+
// Retrying into the same directory converges, and the failed attempt left no
|
|
774
|
+
// phantom export event behind.
|
|
775
|
+
earlyFs.failOn = null
|
|
776
|
+
const retried = await call(early, 'scout_export', { caseId: 'torn-early', targetDir: '/scout/torn-early' }, exec)
|
|
777
|
+
assert.equal(retried.persisted, true)
|
|
778
|
+
assert.deepEqual(eventTypes(earlyFs.read('/scout/torn-early/events.jsonl')), ['case_started', 'case_exported'])
|
|
779
|
+
assert.equal(JSON.parse(earlyFs.read('/scout/torn-early/export.complete.json')).complete, true)
|
|
780
|
+
// The retry brackets the payload with the marker: downgrade first, close last.
|
|
781
|
+
assert.deepEqual(
|
|
782
|
+
earlyFs.writeOrder.slice(-7),
|
|
783
|
+
[EXPORT_MARKER_FILE, ...EXPORT_FILE_NAMES, EXPORT_MARKER_FILE],
|
|
784
|
+
'the marker must be written both before and after the payload',
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
// (b) Interrupted on the closing marker write: all five data files exist, the
|
|
788
|
+
// marker still says `complete: false`, and that is the only thing that says so.
|
|
789
|
+
const lateFs = new MemoryFs()
|
|
790
|
+
const late = createHarness({ config: { scoutDir: '/scout' }, services: { fs: lateFs } })
|
|
791
|
+
await call(late, 'scout_start', { caseId: 'torn-late', companyName: 'Late Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
792
|
+
lateFs.failOn = null
|
|
793
|
+
// Fail only the *second* marker write, i.e. the one that closes the export.
|
|
794
|
+
const originalWrite = lateFs.writeText.bind(lateFs)
|
|
795
|
+
let markerWrites = 0
|
|
796
|
+
lateFs.writeText = async (target, content, intent, signal) => {
|
|
797
|
+
const baseName = target.displayPath.slice(target.displayPath.lastIndexOf('/') + 1)
|
|
798
|
+
if (baseName === EXPORT_MARKER_FILE && ++markerWrites === 2) {
|
|
799
|
+
throw new Error(`simulated write failure on ${EXPORT_MARKER_FILE}`)
|
|
800
|
+
}
|
|
801
|
+
return originalWrite(target, content, intent, signal)
|
|
802
|
+
}
|
|
803
|
+
const lateExport = await call(late, 'scout_export', { caseId: 'torn-late', targetDir: '/scout/torn-late' }, exec)
|
|
804
|
+
assert.equal(lateExport.persisted, false)
|
|
805
|
+
assert.equal(lateExport.complete, false)
|
|
806
|
+
for (const fileName of EXPORT_FILE_NAMES) {
|
|
807
|
+
assert.ok(lateFs.has(`/scout/torn-late/${fileName}`), `${fileName} should have been written before the closing marker`)
|
|
808
|
+
}
|
|
809
|
+
assert.equal(
|
|
810
|
+
JSON.parse(lateFs.read(`/scout/torn-late/${EXPORT_MARKER_FILE}`)).complete,
|
|
811
|
+
false,
|
|
812
|
+
'a run that never closed must leave the marker downgraded',
|
|
813
|
+
)
|
|
814
|
+
const imported = await call(late, 'scout_import', { caseId: 'torn-late', sourceDir: '/scout/torn-late' }, exec)
|
|
815
|
+
assert.equal(imported.caseId, 'torn-late')
|
|
816
|
+
assert.equal(imported.complete, false, 'a downgraded marker must report complete:false')
|
|
817
|
+
|
|
818
|
+
// (c) The same directory *with* a closing marker imports as complete, and
|
|
819
|
+
// deleting the marker afterwards downgrades it: the marker field is the only
|
|
820
|
+
// completeness signal.
|
|
821
|
+
lateFs.failOn = null
|
|
822
|
+
await call(late, 'scout_export', { caseId: 'torn-late', targetDir: '/scout/torn-late' }, exec)
|
|
823
|
+
assert.equal((await call(late, 'scout_import', {
|
|
824
|
+
caseId: 'torn-late', sourceDir: '/scout/torn-late',
|
|
825
|
+
}, exec)).complete, true)
|
|
826
|
+
lateFs.files.delete(`/scout/torn-late/${EXPORT_MARKER_FILE}`)
|
|
827
|
+
const withoutMarker = await call(late, 'scout_import', {
|
|
828
|
+
caseId: 'torn-late', sourceDir: '/scout/torn-late',
|
|
829
|
+
}, exec)
|
|
830
|
+
assert.equal(withoutMarker.complete, false, 'a deleted marker must downgrade the import to incomplete')
|
|
831
|
+
assert.equal(withoutMarker.counts.claims, 0)
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
test('three consecutive exports (complete, interrupted, complete) leave the marker and the event stream in agreement', async () => {
|
|
835
|
+
// The round-2 marker protocol has to survive repeated overwrites, not just one
|
|
836
|
+
// torn write: after a complete export, an interrupted one, and a final complete
|
|
837
|
+
// one, the data on disk and the marker must agree again.
|
|
838
|
+
const fs = new MemoryFs()
|
|
839
|
+
const harness = createHarness({ config: { scoutDir: '/s' }, services: { fs } })
|
|
840
|
+
const exec = execFor()
|
|
841
|
+
const marker = () => JSON.parse(fs.read('/s/tri/export.complete.json'))
|
|
842
|
+
const lines = () => eventTypes(fs.read('/s/tri/events.jsonl'))
|
|
843
|
+
|
|
844
|
+
await call(harness, 'scout_start', { caseId: 'tri', companyName: 'Tri Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
845
|
+
const first = await call(harness, 'scout_export', { caseId: 'tri', targetDir: '/s/tri' }, exec)
|
|
846
|
+
assert.equal(first.complete, true)
|
|
847
|
+
assert.equal(marker().complete, true)
|
|
848
|
+
assert.equal(marker().eventCount, lines().length)
|
|
849
|
+
assert.deepEqual(lines(), ['case_started', 'case_exported'])
|
|
850
|
+
|
|
851
|
+
await call(harness, 'scout_add_source', {
|
|
852
|
+
caseId: 'tri', sourceId: 'src', sourceType: 'other', title: 'Source',
|
|
853
|
+
url: 'https://example.com/src', evidenceLevel: 'E2',
|
|
854
|
+
}, exec)
|
|
855
|
+
fs.failOn = 'events.jsonl'
|
|
856
|
+
const second = await call(harness, 'scout_export', { caseId: 'tri', targetDir: '/s/tri' }, exec)
|
|
857
|
+
assert.equal(second.persisted, false)
|
|
858
|
+
assert.equal(second.complete, false)
|
|
859
|
+
assert.equal(marker().complete, false, 'the interrupted run leaves the marker invalid')
|
|
860
|
+
assert.deepEqual(lines(), ['case_started', 'case_exported'], 'and leaves the old event stream intact')
|
|
861
|
+
|
|
862
|
+
fs.failOn = null
|
|
863
|
+
const third = await call(harness, 'scout_export', { caseId: 'tri', targetDir: '/s/tri' }, exec)
|
|
864
|
+
assert.equal(third.complete, true)
|
|
865
|
+
assert.equal(marker().complete, true)
|
|
866
|
+
assert.deepEqual(lines(), ['case_started', 'case_exported', 'source_added', 'case_exported'])
|
|
867
|
+
assert.equal(marker().eventCount, lines().length, 'the marker and events.jsonl agree again')
|
|
868
|
+
assert.equal(JSON.parse(fs.read('/s/tri/sources.json')).length, 1)
|
|
869
|
+
|
|
870
|
+
const imported = await call(harness, 'scout_import', { caseId: 'tri', sourceDir: '/s/tri' }, exec)
|
|
871
|
+
assert.equal(imported.complete, true)
|
|
872
|
+
assert.equal(imported.counts.sources, 1)
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
test('scout_import tells "no completion marker" apart from "a data file is missing"', async () => {
|
|
876
|
+
// The completeness flag and the hard failure are two different signals: the
|
|
877
|
+
// marker decides `complete`, the presence of the five data files decides
|
|
878
|
+
// whether the import can happen at all.
|
|
879
|
+
const buildComplete = async () => {
|
|
880
|
+
const fs = new MemoryFs()
|
|
881
|
+
const writer = createHarness({ config: { scoutDir: '/s' }, services: { fs } })
|
|
882
|
+
const exec = execFor()
|
|
883
|
+
await call(writer, 'scout_start', { caseId: 'mx', companyName: 'MX Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
884
|
+
await call(writer, 'scout_add_source', {
|
|
885
|
+
caseId: 'mx', sourceId: 'src', sourceType: 'other', title: 'Source',
|
|
886
|
+
url: 'https://example.com/src', evidenceLevel: 'E2',
|
|
887
|
+
}, exec)
|
|
888
|
+
await call(writer, 'scout_export', { caseId: 'mx', targetDir: '/s/mx' }, exec)
|
|
889
|
+
return fs
|
|
890
|
+
}
|
|
891
|
+
const rewriteMarker = (fs, marker) => {
|
|
892
|
+
fs.files.set('/s/mx/export.complete.json', marker === null ? '' : `${JSON.stringify(marker)}\n`)
|
|
893
|
+
}
|
|
894
|
+
const importOnce = async fs => {
|
|
895
|
+
const reader = createHarness({ config: { scoutDir: '/s' }, services: { fs } })
|
|
896
|
+
return call(reader, 'scout_import', { caseId: 'mx', sourceDir: '/s/mx' }, execFor())
|
|
897
|
+
.then(value => ({ outcome: 'imported', complete: value.complete, counts: value.counts }), error => ({ outcome: 'threw', message: error.message }))
|
|
898
|
+
}
|
|
899
|
+
const rehydrateOnce = async fs => {
|
|
900
|
+
const reader = createHarness({ config: { scoutDir: '/s' }, services: { fs } })
|
|
901
|
+
return call(reader, 'scout_report', { caseId: 'mx' }, execFor())
|
|
902
|
+
.then(() => 'rehydrated', () => 'refused')
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const cases = []
|
|
906
|
+
{
|
|
907
|
+
const fs = await buildComplete()
|
|
908
|
+
cases.push(['closing marker complete:true', fs, { imported: true }])
|
|
909
|
+
}
|
|
910
|
+
{
|
|
911
|
+
const fs = await buildComplete()
|
|
912
|
+
fs.files.delete('/s/mx/export.complete.json')
|
|
913
|
+
cases.push(['marker file deleted', fs, { imported: false }])
|
|
914
|
+
}
|
|
915
|
+
{
|
|
916
|
+
const fs = await buildComplete()
|
|
917
|
+
rewriteMarker(fs, { schemaVersion: 'dsh-scout.export.v1', caseId: 'mx', complete: false, files: [...EXPORT_FILE_NAMES], eventCount: 2 })
|
|
918
|
+
cases.push(['marker says complete:false', fs, { imported: false }])
|
|
919
|
+
}
|
|
920
|
+
{
|
|
921
|
+
const fs = await buildComplete()
|
|
922
|
+
fs.files.set('/s/mx/export.complete.json', 'not json at all')
|
|
923
|
+
cases.push(['marker is unparsable', fs, { imported: false }])
|
|
924
|
+
}
|
|
925
|
+
{
|
|
926
|
+
const fs = await buildComplete()
|
|
927
|
+
fs.files.delete('/s/mx/claims.json')
|
|
928
|
+
cases.push(['marker complete:true but a data file is missing', fs, { throws: true }])
|
|
929
|
+
}
|
|
930
|
+
{
|
|
931
|
+
const fs = await buildComplete()
|
|
932
|
+
fs.files.delete('/s/mx/export.complete.json')
|
|
933
|
+
fs.files.delete('/s/mx/claims.json')
|
|
934
|
+
cases.push(['no marker and a data file missing', fs, { throws: true }])
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
for (const [label, fs, expected] of cases) {
|
|
938
|
+
const imported = await importOnce(fs)
|
|
939
|
+
if (expected.throws) {
|
|
940
|
+
assert.equal(imported.outcome, 'threw', `${label}: a missing data file must fail hard`)
|
|
941
|
+
assert.match(imported.message, /claims\.json/, `${label}: the failure must name the missing file`)
|
|
942
|
+
} else {
|
|
943
|
+
assert.equal(imported.outcome, 'imported', `${label}: import must succeed`)
|
|
944
|
+
assert.equal(imported.complete, expected.imported, `${label}: wrong complete flag`)
|
|
945
|
+
assert.equal(imported.counts.sources, 1)
|
|
946
|
+
}
|
|
947
|
+
assert.equal(
|
|
948
|
+
await rehydrateOnce(fs),
|
|
949
|
+
expected.imported ? 'rehydrated' : 'refused',
|
|
950
|
+
`${label}: rehydration follows the same rule`,
|
|
951
|
+
)
|
|
952
|
+
}
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
test('rehydration requires a completion marker asserting complete:true and refuses torn or legacy exports', async () => {
|
|
956
|
+
// Round 1 pinned the opposite: rehydration only required the four data files,
|
|
957
|
+
// so an export interrupted at `report.md` was adopted silently — including a
|
|
958
|
+
// `case_exported` event the tool had reported as failed. Round 2 makes the
|
|
959
|
+
// marker's `complete` field the gate, so this test now asserts the refusal.
|
|
960
|
+
const fs = new MemoryFs()
|
|
961
|
+
const writer = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
|
|
962
|
+
const exec = execFor()
|
|
963
|
+
await call(writer, 'scout_start', { caseId: 'silent', companyName: 'Silent Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
964
|
+
|
|
965
|
+
// Interrupted after four of five data files: the marker is left downgraded.
|
|
966
|
+
fs.failOn = 'report.md'
|
|
967
|
+
const torn = await call(writer, 'scout_export', { caseId: 'silent', targetDir: '/scout/silent' }, exec)
|
|
968
|
+
assert.equal(torn.persisted, false)
|
|
969
|
+
assert.equal(JSON.parse(fs.read('/scout/silent/export.complete.json')).complete, false)
|
|
970
|
+
assert.ok(!fs.has('/scout/silent/report.md'))
|
|
971
|
+
|
|
972
|
+
const reader = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
|
|
973
|
+
await assert.rejects(
|
|
974
|
+
() => call(reader, 'scout_report', { caseId: 'silent' }, execFor()),
|
|
975
|
+
error => {
|
|
976
|
+
assert.match(error.message, /Unknown case: silent/, 'an invalidated export must not be rehydrated')
|
|
977
|
+
assert.match(error.message, /scout_import/, 'the error must still point at the explicit restore path')
|
|
978
|
+
return true
|
|
979
|
+
},
|
|
980
|
+
)
|
|
981
|
+
// ... while the explicit import still works and reports the truth.
|
|
982
|
+
const imported = await call(reader, 'scout_import', { caseId: 'silent', sourceDir: '/scout/silent' }, exec)
|
|
983
|
+
assert.equal(imported.complete, false)
|
|
984
|
+
assert.equal(imported.counts.sources, 0)
|
|
985
|
+
|
|
986
|
+
// Healing the export makes it rehydratable again.
|
|
987
|
+
fs.failOn = null
|
|
988
|
+
await call(writer, 'scout_export', { caseId: 'silent', targetDir: '/scout/silent' }, exec)
|
|
989
|
+
assert.equal(JSON.parse(fs.read('/scout/silent/export.complete.json')).complete, true)
|
|
990
|
+
const healed = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
|
|
991
|
+
const report = await call(healed, 'scout_report', { caseId: 'silent' }, execFor())
|
|
992
|
+
assert.match(report.report, /^# Silent Co — HR Head$/m)
|
|
993
|
+
await call(healed, 'scout_export', { caseId: 'silent', targetDir: '/scout/silent-2' }, execFor())
|
|
994
|
+
assert.deepEqual(eventTypes(fs.read('/scout/silent-2/events.jsonl')), [
|
|
995
|
+
'case_started', 'case_exported', 'case_rehydrated', 'case_exported',
|
|
996
|
+
], 'a completed export rehydrates and records exactly one case_rehydrated')
|
|
997
|
+
|
|
998
|
+
// A legacy 0.9.0 export has no marker at all: it must be restored explicitly
|
|
999
|
+
// with `scout_import`, not silently adopted on a cache miss.
|
|
1000
|
+
fs.files.delete('/scout/silent/export.complete.json')
|
|
1001
|
+
const legacy = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
|
|
1002
|
+
await assert.rejects(
|
|
1003
|
+
() => call(legacy, 'scout_report', { caseId: 'silent' }, execFor()),
|
|
1004
|
+
/Unknown case: silent/,
|
|
1005
|
+
'a markerless legacy export must not be rehydrated',
|
|
1006
|
+
)
|
|
1007
|
+
const legacyImport = await call(legacy, 'scout_import', { caseId: 'silent', sourceDir: '/scout/silent' }, exec)
|
|
1008
|
+
assert.equal(legacyImport.complete, false, 'a legacy export still imports, honestly marked incomplete')
|
|
1009
|
+
assert.match((await call(legacy, 'scout_report', { caseId: 'silent' }, exec)).report, /Silent Co/)
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
/* ------------------------------------------------------------------ *
|
|
1013
|
+
* 8. Call-site service binding, replacement, forwarding and disposal
|
|
1014
|
+
* ------------------------------------------------------------------ */
|
|
1015
|
+
|
|
1016
|
+
test('services mounted after apply() are used, a replaced provider wins, and disposal removes every registration', async () => {
|
|
1017
|
+
const harness = createHarness()
|
|
1018
|
+
assert.equal(harness.tools.size, 12, 'tools register even with no host service present')
|
|
1019
|
+
assert.deepEqual(harness.injections.map(entry => entry.dependencies), [['commands'], ['skills']])
|
|
1020
|
+
|
|
1021
|
+
const exec = execFor('late', '/session/cwd')
|
|
1022
|
+
await call(harness, 'scout_start', { caseId: 'late', companyName: 'Late Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1023
|
+
|
|
1024
|
+
// `web` appears only now; `scout_search` must still see it.
|
|
1025
|
+
harness.ctx.mount('web', webStub(['https://www.gsxt.gov.cn/co/1']))
|
|
1026
|
+
const found = await call(harness, 'scout_search', { caseId: 'late', query: 'late' }, exec)
|
|
1027
|
+
assert.equal(found.counts.sources, 1)
|
|
1028
|
+
|
|
1029
|
+
// `fs` appears only now; `scout_export` must still see it, and a *replaced*
|
|
1030
|
+
// provider must be the one that receives the writes.
|
|
1031
|
+
const firstFs = new MemoryFs()
|
|
1032
|
+
harness.ctx.mount('fs', firstFs)
|
|
1033
|
+
assert.equal((await call(harness, 'scout_export', { caseId: 'late', targetDir: 'relative/dir' }, exec)).persisted, true)
|
|
1034
|
+
assert.ok(firstFs.has('relative/dir/case.json'))
|
|
1035
|
+
|
|
1036
|
+
const secondFs = new MemoryFs()
|
|
1037
|
+
harness.ctx.mount('fs', secondFs)
|
|
1038
|
+
assert.equal((await call(harness, 'scout_export', { caseId: 'late', targetDir: 'relative/dir' }, exec)).persisted, true)
|
|
1039
|
+
assert.ok(secondFs.has('relative/dir/case.json'), 'the replacement provider must receive the write')
|
|
1040
|
+
assert.equal(firstFs.list().filter(path => path.includes('export.complete.json')).length, 1, 'the old provider must not be used again')
|
|
1041
|
+
|
|
1042
|
+
// The session cwd and the caller signal are forwarded to the write.
|
|
1043
|
+
const resolveCall = secondFs.calls.find(entry => entry.op === 'resolve')
|
|
1044
|
+
assert.equal(resolveCall.options.cwd, '/session/cwd', 'relative export paths resolve against the session cwd')
|
|
1045
|
+
assert.equal(resolveCall.options.signal, exec.signal)
|
|
1046
|
+
const writeCall = secondFs.calls.find(entry => entry.op === 'writeText')
|
|
1047
|
+
assert.equal(writeCall.signal, exec.signal, 'the write must forward exec.signal')
|
|
1048
|
+
|
|
1049
|
+
// Optional host services registered after apply() are reached through inject.
|
|
1050
|
+
const commands = []
|
|
1051
|
+
const skills = []
|
|
1052
|
+
harness.ctx.mount('commands', { register: definition => { commands.push(definition); return () => commands.pop() } })
|
|
1053
|
+
harness.ctx.mount('skills', { register: definition => { skills.push(definition); return () => skills.pop() } })
|
|
1054
|
+
assert.deepEqual(commands.map(definition => definition.name), ['scout'])
|
|
1055
|
+
assert.deepEqual(skills.map(definition => definition.name), ['scout'])
|
|
1056
|
+
|
|
1057
|
+
// Hold the tool object across disposal: the cache clearing is what must make
|
|
1058
|
+
// the call fail, not the registry lookup.
|
|
1059
|
+
const reportTool = harness.tools.get('scout_report')
|
|
1060
|
+
harness.dispose()
|
|
1061
|
+
assert.equal(harness.tools.size, 0, 'unload must remove every tool')
|
|
1062
|
+
assert.equal(commands.length, 0, 'unload must dispose the command registration')
|
|
1063
|
+
assert.equal(skills.length, 0, 'unload must dispose the skill registration')
|
|
1064
|
+
await assert.rejects(
|
|
1065
|
+
() => reportTool.execute({ caseId: 'late' }, exec),
|
|
1066
|
+
/Unknown case: late/,
|
|
1067
|
+
'unload must clear the in-memory case cache',
|
|
1068
|
+
)
|
|
1069
|
+
})
|
|
1070
|
+
|
|
1071
|
+
/* ------------------------------------------------------------------ *
|
|
1072
|
+
* 9. Lazy rehydration
|
|
1073
|
+
* ------------------------------------------------------------------ */
|
|
1074
|
+
|
|
1075
|
+
test('a cache miss rehydrates from <scoutDir>/<caseId>/, records it, and refuses a mismatched case id', async () => {
|
|
1076
|
+
const fs = new MemoryFs()
|
|
1077
|
+
const writer = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
|
|
1078
|
+
const exec = execFor()
|
|
1079
|
+
await call(writer, 'scout_start', { caseId: 'restored', companyName: 'Restored Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1080
|
+
await call(writer, 'scout_export', { caseId: 'restored', targetDir: '/scout/restored' }, exec)
|
|
1081
|
+
assert.ok(fs.has('/scout/restored/export.complete.json'))
|
|
1082
|
+
|
|
1083
|
+
const reader = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
|
|
1084
|
+
const report = await call(reader, 'scout_report', { caseId: 'restored' }, exec)
|
|
1085
|
+
assert.match(report.report, /^# Restored Co — HR Head$/m)
|
|
1086
|
+
await call(reader, 'scout_export', { caseId: 'restored', targetDir: '/scout/restored-2' }, exec)
|
|
1087
|
+
assert.deepEqual(eventTypes(fs.read('/scout/restored-2/events.jsonl')), [
|
|
1088
|
+
'case_started', 'case_exported', 'case_rehydrated', 'case_exported',
|
|
1089
|
+
])
|
|
1090
|
+
|
|
1091
|
+
// An export of a *different* case sitting in this case's directory must not be
|
|
1092
|
+
// adopted: rehydration is keyed by directory, and the id inside must match.
|
|
1093
|
+
const other = createHarness({ services: { fs } })
|
|
1094
|
+
await call(other, 'scout_start', { caseId: 'other', companyName: 'Other Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1095
|
+
await call(other, 'scout_export', { caseId: 'other', targetDir: '/scout/wanted' }, exec)
|
|
1096
|
+
const third = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
|
|
1097
|
+
await assert.rejects(
|
|
1098
|
+
() => call(third, 'scout_report', { caseId: 'wanted' }, execFor()),
|
|
1099
|
+
error => {
|
|
1100
|
+
assert.match(error.message, /Unknown case: wanted/)
|
|
1101
|
+
assert.match(error.message, /scout_import/, 'the error must suggest how to restore the case')
|
|
1102
|
+
return true
|
|
1103
|
+
},
|
|
1104
|
+
)
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
/* ------------------------------------------------------------------ *
|
|
1108
|
+
* 10. Language
|
|
1109
|
+
* ------------------------------------------------------------------ */
|
|
1110
|
+
|
|
1111
|
+
test('language: en renders every human surface, including the persisted report.md, in English', async () => {
|
|
1112
|
+
// Scope note: this covers claims added through `scout_add_claim`, i.e. every
|
|
1113
|
+
// string in the result is either framework text or caller-supplied. The
|
|
1114
|
+
// `scout_ingest` draft path is covered separately by the DEFECT D7 test below,
|
|
1115
|
+
// which shows it is NOT language-neutral.
|
|
1116
|
+
const fs = new MemoryFs()
|
|
1117
|
+
const harness = createHarness({ config: { language: 'en' }, services: { fs } })
|
|
1118
|
+
const exec = execFor()
|
|
1119
|
+
await call(harness, 'scout_start', { caseId: 'en-a', companyName: 'Example Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1120
|
+
await call(harness, 'scout_start', { caseId: 'en-b', companyName: 'Second Co', roleTitle: 'CTO', location: '' }, exec)
|
|
1121
|
+
|
|
1122
|
+
const report = await call(harness, 'scout_report', { caseId: 'en-a' }, exec)
|
|
1123
|
+
assert.match(report.report, /- Decision: \*\*VERIFY\*\*/)
|
|
1124
|
+
assert.match(report.report, /- Reason: /)
|
|
1125
|
+
assert.match(report.report, /- Objective: /)
|
|
1126
|
+
assert.match(report.report, /- No sources yet/)
|
|
1127
|
+
assert.equal(CJK.test(report.report), false, 'an English report must not contain CJK characters')
|
|
1128
|
+
|
|
1129
|
+
const questions = await call(harness, 'scout_questions', { caseId: 'en-a' }, exec)
|
|
1130
|
+
assert.ok(questions.questions.length > 0)
|
|
1131
|
+
assert.equal(CJK.test(questions.questions.join('')), false, 'English questions must not contain CJK')
|
|
1132
|
+
|
|
1133
|
+
const comparison = await call(harness, 'scout_compare', { caseIds: 'en-a,en-b' }, exec)
|
|
1134
|
+
assert.match(comparison.report, /# Company \/ role comparison \(2 cases\)/)
|
|
1135
|
+
assert.equal(CJK.test(comparison.report), false, 'an English comparison must not contain CJK')
|
|
1136
|
+
|
|
1137
|
+
// The export writes report.md through `exportCaseFiles`, a different code path
|
|
1138
|
+
// from `scout_report`; both must be English.
|
|
1139
|
+
const exported = await call(harness, 'scout_export', { caseId: 'en-a', targetDir: '/export/en' }, exec)
|
|
1140
|
+
assert.equal(exported.persisted, true)
|
|
1141
|
+
const persisted = fs.read('/export/en/report.md')
|
|
1142
|
+
assert.equal(CJK.test(persisted), false, 'the persisted report.md must be English too')
|
|
1143
|
+
assert.equal(persisted, report.report, 'the persisted snapshot must equal the rendered report')
|
|
1144
|
+
const caseJson = JSON.parse(fs.read('/export/en/case.json'))
|
|
1145
|
+
assert.equal(caseJson.language, 'en')
|
|
1146
|
+
assert.equal(CJK.test(caseJson.decisionReason), false)
|
|
1147
|
+
|
|
1148
|
+
// The configured language must survive a round trip through an export.
|
|
1149
|
+
const reader = createHarness({ config: { language: 'en', scoutDir: '/export' }, services: { fs } })
|
|
1150
|
+
await call(reader, 'scout_import', { caseId: 'en-a', sourceDir: '/export/en' }, execFor())
|
|
1151
|
+
const reread = await call(reader, 'scout_report', { caseId: 'en-a' }, execFor())
|
|
1152
|
+
assert.match(reread.report, /- Decision: \*\*VERIFY\*\*/)
|
|
1153
|
+
})
|
|
1154
|
+
|
|
1155
|
+
test('scout_ingest drafts its claim notes in the deployment language', async () => {
|
|
1156
|
+
// Round 1 pinned the opposite: the ingest draft path hard-coded Chinese
|
|
1157
|
+
// `confidenceNote` / `nextAction`, so an `en` case built through the documented
|
|
1158
|
+
// start -> ingest workflow produced 78 CJK characters in the report and in the
|
|
1159
|
+
// persisted report.md. Round 2 moved both strings into `i18n.ts`; this test now
|
|
1160
|
+
// asserts both languages, including that `zh-CN` is unchanged.
|
|
1161
|
+
const ingestOne = async (language, caseId) => {
|
|
1162
|
+
const fs = new MemoryFs()
|
|
1163
|
+
const harness = createHarness({ config: { language }, services: { fs } })
|
|
1164
|
+
const exec = execFor()
|
|
1165
|
+
await call(harness, 'scout_start', { caseId, companyName: 'Example Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1166
|
+
await call(harness, 'scout_ingest', {
|
|
1167
|
+
caseId,
|
|
1168
|
+
itemsJson: JSON.stringify([{
|
|
1169
|
+
url: 'https://example.com/a',
|
|
1170
|
+
title: 'Report A',
|
|
1171
|
+
claim: { text: 'The role exists.', dimension: 'role_existence' },
|
|
1172
|
+
}]),
|
|
1173
|
+
}, exec)
|
|
1174
|
+
const report = await call(harness, 'scout_report', { caseId }, exec)
|
|
1175
|
+
const questions = await call(harness, 'scout_questions', { caseId }, exec)
|
|
1176
|
+
await call(harness, 'scout_export', { caseId, targetDir: `/export/${caseId}` }, exec)
|
|
1177
|
+
return { report: report.report, questions: questions.questions, persisted: fs.read(`/export/${caseId}/report.md`) }
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const english = await ingestOne('en', 'en-ingest')
|
|
1181
|
+
assert.equal(CJK.test(english.report), false, 'an English report must have no CJK on the ingest path either')
|
|
1182
|
+
assert.equal(CJK.test(english.persisted), false, 'and the persisted report.md must be English too')
|
|
1183
|
+
assert.equal(CJK.test(english.questions.join('')), false, 'and so must the derived interview questions')
|
|
1184
|
+
assert.match(
|
|
1185
|
+
english.report,
|
|
1186
|
+
/confidence: Auto-registered from an ingested item; source src-1; next action: Verify this claim and add an independent source/,
|
|
1187
|
+
)
|
|
1188
|
+
assert.equal(english.persisted, english.report, 'the persisted snapshot stays byte-equal to the report')
|
|
1189
|
+
|
|
1190
|
+
const chinese = await ingestOne('zh-CN', 'zh-ingest')
|
|
1191
|
+
assert.match(
|
|
1192
|
+
chinese.report,
|
|
1193
|
+
/置信说明:由采集项自动登记,来源 src-1;下一步:核验该主张并补充独立来源/,
|
|
1194
|
+
'the zh-CN draft wording is unchanged, so the 0.9.0 byte-compat baseline still holds',
|
|
1195
|
+
)
|
|
1196
|
+
assert.equal(CJK.test(chinese.report), true)
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
test('the default zh-CN report keeps its pre-0.9.1 bytes and only adds the Coverage block', async () => {
|
|
1200
|
+
const defaults = new Config({})
|
|
1201
|
+
assert.equal(defaults.language, 'zh-CN')
|
|
1202
|
+
assert.equal(defaults.scoutDir, 'dsh-scout')
|
|
1203
|
+
assert.equal(defaults.autoPersist, false)
|
|
1204
|
+
|
|
1205
|
+
const harness = createHarness({ services: { fs: new MemoryFs() } })
|
|
1206
|
+
const exec = execFor()
|
|
1207
|
+
await call(harness, 'scout_start', { caseId: 'zh', companyName: 'Example Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1208
|
+
const report = await call(harness, 'scout_report', { caseId: 'zh' }, exec)
|
|
1209
|
+
|
|
1210
|
+
// Section headings are English in both languages — that is the shipped 0.9.0
|
|
1211
|
+
// shape, verified against `git show HEAD:src/model.ts`, not a translation gap.
|
|
1212
|
+
assert.deepEqual(
|
|
1213
|
+
report.report.split('\n').filter(line => line.startsWith('## ')),
|
|
1214
|
+
[
|
|
1215
|
+
'## Coverage',
|
|
1216
|
+
'## Key supporting evidence (up to 3)',
|
|
1217
|
+
'## Blocking or unresolved risks (up to 3)',
|
|
1218
|
+
'## Role task and authority hypotheses',
|
|
1219
|
+
'## Verification checklist',
|
|
1220
|
+
'## Claim ledger',
|
|
1221
|
+
'## Sources',
|
|
1222
|
+
'## Interview questions',
|
|
1223
|
+
],
|
|
1224
|
+
)
|
|
1225
|
+
assert.match(report.report, /^- 判断:\*\*VERIFY\*\*$/m)
|
|
1226
|
+
assert.match(report.report, /^- 理由:尚未完成公司主体和岗位授权核验。$/m)
|
|
1227
|
+
assert.match(report.report, /^- 目标:判断是否值得进入下一轮,并准备面试反问$/m)
|
|
1228
|
+
assert.match(report.report, /^- 暂无来源$/m)
|
|
1229
|
+
assert.equal(CJK.test(report.report), true)
|
|
1230
|
+
|
|
1231
|
+
// `## Coverage` reports all eleven dimensions, in CLAIM_DIMENSIONS order, and
|
|
1232
|
+
// the structured array must agree with the rendered block.
|
|
1233
|
+
assert.equal(report.coverage.length, CLAIM_DIMENSIONS.length)
|
|
1234
|
+
assert.deepEqual(report.coverage.map(entry => entry.dimension), [...CLAIM_DIMENSIONS])
|
|
1235
|
+
assert.ok(report.coverage.every(entry => entry.untouched === true))
|
|
1236
|
+
const coverageBlock = report.report.slice(
|
|
1237
|
+
report.report.indexOf('## Coverage'),
|
|
1238
|
+
report.report.indexOf('## Key supporting evidence'),
|
|
1239
|
+
)
|
|
1240
|
+
for (const dimension of CLAIM_DIMENSIONS) {
|
|
1241
|
+
assert.ok(coverageBlock.includes(`- ${dimension}(`), `coverage block is missing ${dimension}`)
|
|
1242
|
+
}
|
|
1243
|
+
assert.match(coverageBlock, /未调查维度(11):/)
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
test('a case crossing a language boundary renders every surface, report.md included, in the deployment language', async () => {
|
|
1247
|
+
// Round 1 pinned the opposite: `exportCaseFiles` rendered with no language
|
|
1248
|
+
// override, so an `en` deployment importing a zh-CN case returned an English
|
|
1249
|
+
// `scout_report` but persisted a Chinese `report.md`. Round 2 threads the
|
|
1250
|
+
// deployment language through the export, so both are now the same artifact.
|
|
1251
|
+
const fs = new MemoryFs()
|
|
1252
|
+
const zh = createHarness({ services: { fs } })
|
|
1253
|
+
const exec = execFor()
|
|
1254
|
+
await call(zh, 'scout_start', { caseId: 'mixed', companyName: 'Mixed Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1255
|
+
await call(zh, 'scout_export', { caseId: 'mixed', targetDir: '/export/zh' }, exec)
|
|
1256
|
+
|
|
1257
|
+
const en = createHarness({ config: { language: 'en' }, services: { fs } })
|
|
1258
|
+
await call(en, 'scout_import', { caseId: 'mixed', sourceDir: '/export/zh' }, exec)
|
|
1259
|
+
const report = await call(en, 'scout_report', { caseId: 'mixed' }, exec)
|
|
1260
|
+
await call(en, 'scout_export', { caseId: 'mixed', targetDir: '/export/en-from-zh' }, exec)
|
|
1261
|
+
|
|
1262
|
+
assert.match(report.report, /^- Decision: \*\*VERIFY\*\*$/m, 'scout_report follows the configured language')
|
|
1263
|
+
assert.match(report.report, /^- Reason: /m)
|
|
1264
|
+
assert.match(report.report, /^- No sources yet$/m)
|
|
1265
|
+
const persisted = fs.read('/export/en-from-zh/report.md')
|
|
1266
|
+
assert.equal(persisted, report.report, 'the persisted snapshot and the rendered report are the same artifact')
|
|
1267
|
+
assert.doesNotMatch(persisted, /^- 判断:/, 'report.md no longer falls back to the case language')
|
|
1268
|
+
|
|
1269
|
+
const caseJson = JSON.parse(fs.read('/export/en-from-zh/case.json'))
|
|
1270
|
+
assert.equal(caseJson.language, 'en', 'the case label follows the deployment language')
|
|
1271
|
+
assert.equal(CJK.test(caseJson.decisionReason), false, 'and the decision reason is rendered in it')
|
|
1272
|
+
|
|
1273
|
+
// The mirror direction: a zh-CN deployment importing an en case.
|
|
1274
|
+
const fs2 = new MemoryFs()
|
|
1275
|
+
const enWriter = createHarness({ config: { language: 'en' }, services: { fs: fs2 } })
|
|
1276
|
+
await call(enWriter, 'scout_start', { caseId: 'rev', companyName: 'Rev Co', roleTitle: 'HR Head', location: '' }, execFor())
|
|
1277
|
+
await call(enWriter, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-en' }, execFor())
|
|
1278
|
+
const zhReader = createHarness({ services: { fs: fs2 } })
|
|
1279
|
+
await call(zhReader, 'scout_import', { caseId: 'rev', sourceDir: '/export/rev-en' }, execFor())
|
|
1280
|
+
const zhReport = await call(zhReader, 'scout_report', { caseId: 'rev' }, execFor())
|
|
1281
|
+
await call(zhReader, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-zh' }, execFor())
|
|
1282
|
+
assert.match(zhReport.report, /^- 判断:\*\*VERIFY\*\*$/m)
|
|
1283
|
+
assert.equal(fs2.read('/export/rev-zh/report.md'), zhReport.report)
|
|
1284
|
+
assert.equal(JSON.parse(fs2.read('/export/rev-zh/case.json')).language, 'zh-CN')
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
test('a case crossing a language boundary is single-language in both directions, on the import and rehydrate paths', async () => {
|
|
1288
|
+
// Round 1 pinned the render/export mismatch (D1) and round 2 pinned the residue
|
|
1289
|
+
// it left behind (D8): the case-stored prose fields kept the creation language,
|
|
1290
|
+
// so an "English" report carried a Chinese objective and five Chinese curated
|
|
1291
|
+
// questions while `case.json` claimed `language: "en"`. Round 3 aligns the
|
|
1292
|
+
// *untouched factory* values with the deployment language, so this test now
|
|
1293
|
+
// asserts a fully single-language artifact.
|
|
1294
|
+
const zhFactory = scoutStrings('zh-CN').defaults
|
|
1295
|
+
const enFactory = scoutStrings('en').defaults
|
|
1296
|
+
|
|
1297
|
+
// --- zh-CN case into an `en` deployment, via the explicit import path -------
|
|
1298
|
+
const fs = new MemoryFs()
|
|
1299
|
+
const zh = createHarness({ services: { fs } })
|
|
1300
|
+
const exec = execFor()
|
|
1301
|
+
await call(zh, 'scout_start', { caseId: 'mixed', companyName: 'Mixed Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1302
|
+
await call(zh, 'scout_export', { caseId: 'mixed', targetDir: '/export/zh' }, exec)
|
|
1303
|
+
const originalZhCase = JSON.parse(fs.read('/export/zh/case.json'))
|
|
1304
|
+
assert.equal(originalZhCase.language, 'zh-CN')
|
|
1305
|
+
assert.deepEqual(originalZhCase.interviewQuestions, zhFactory.interviewQuestions)
|
|
1306
|
+
|
|
1307
|
+
const en = createHarness({ config: { language: 'en' }, services: { fs } })
|
|
1308
|
+
await call(en, 'scout_import', { caseId: 'mixed', sourceDir: '/export/zh' }, exec)
|
|
1309
|
+
const report = await call(en, 'scout_report', { caseId: 'mixed' }, exec)
|
|
1310
|
+
const questions = await call(en, 'scout_questions', { caseId: 'mixed' }, exec)
|
|
1311
|
+
await call(en, 'scout_export', { caseId: 'mixed', targetDir: '/export/en-from-zh' }, exec)
|
|
1312
|
+
const caseJson = JSON.parse(fs.read('/export/en-from-zh/case.json'))
|
|
1313
|
+
|
|
1314
|
+
assert.equal(CJK.test(report.report), false, 'the English report must contain no CJK at all')
|
|
1315
|
+
assert.equal(CJK.test(questions.questions.join('')), false, 'nor may the question list')
|
|
1316
|
+
assert.match(report.report, new RegExp(`^- Objective: ${enFactory.decisionObjective.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'))
|
|
1317
|
+
assert.equal(caseJson.language, 'en')
|
|
1318
|
+
assert.equal(caseJson.decisionObjective, enFactory.decisionObjective)
|
|
1319
|
+
assert.deepEqual(caseJson.interviewQuestions, enFactory.interviewQuestions)
|
|
1320
|
+
assert.equal(caseJson.role.fitQuestion, enFactory.fitQuestion)
|
|
1321
|
+
assert.deepEqual(questions.questions.slice(-5), enFactory.interviewQuestions, 'the curated tail is the English factory set')
|
|
1322
|
+
|
|
1323
|
+
// --- en case into a zh-CN deployment: the mirror image ---------------------
|
|
1324
|
+
const fs2 = new MemoryFs()
|
|
1325
|
+
const enWriter = createHarness({ config: { language: 'en' }, services: { fs: fs2 } })
|
|
1326
|
+
await call(enWriter, 'scout_start', { caseId: 'rev', companyName: 'Rev Co', roleTitle: 'HR Head', location: '' }, execFor())
|
|
1327
|
+
await call(enWriter, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-en' }, execFor())
|
|
1328
|
+
const zhReader = createHarness({ services: { fs: fs2 } })
|
|
1329
|
+
await call(zhReader, 'scout_import', { caseId: 'rev', sourceDir: '/export/rev-en' }, execFor())
|
|
1330
|
+
const zhReport = await call(zhReader, 'scout_report', { caseId: 'rev' }, execFor())
|
|
1331
|
+
const zhQuestions = await call(zhReader, 'scout_questions', { caseId: 'rev' }, execFor())
|
|
1332
|
+
await call(zhReader, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-zh' }, execFor())
|
|
1333
|
+
const zhCase = JSON.parse(fs2.read('/export/rev-zh/case.json'))
|
|
1334
|
+
|
|
1335
|
+
// No English factory prose may survive in a zh-CN deployment.
|
|
1336
|
+
for (const sentinel of [enFactory.decisionObjective, enFactory.fitQuestion, ...enFactory.interviewQuestions]) {
|
|
1337
|
+
assert.ok(!zhReport.report.includes(sentinel), `English factory prose leaked into the zh report: ${sentinel}`)
|
|
1338
|
+
assert.ok(!zhQuestions.questions.includes(sentinel), `English factory prose leaked into the zh questions: ${sentinel}`)
|
|
1339
|
+
}
|
|
1340
|
+
assert.equal(zhCase.language, 'zh-CN')
|
|
1341
|
+
assert.equal(zhCase.decisionObjective, zhFactory.decisionObjective)
|
|
1342
|
+
assert.deepEqual(zhCase.interviewQuestions, zhFactory.interviewQuestions)
|
|
1343
|
+
assert.equal(zhCase.role.fitQuestion, zhFactory.fitQuestion)
|
|
1344
|
+
|
|
1345
|
+
// --- the lazy-rehydration path must align as well, not only scout_import ---
|
|
1346
|
+
const fs3 = new MemoryFs()
|
|
1347
|
+
const zhWriter = createHarness({ scoutDir: '/scout', services: { fs: fs3 } })
|
|
1348
|
+
await call(zhWriter, 'scout_start', { caseId: 'rehy', companyName: 'Rehy Co', roleTitle: 'HR Head', location: '' }, execFor())
|
|
1349
|
+
await call(zhWriter, 'scout_export', { caseId: 'rehy', targetDir: '/scout/rehy' }, execFor())
|
|
1350
|
+
const enReader = createHarness({ config: { language: 'en', scoutDir: '/scout' }, services: { fs: fs3 } })
|
|
1351
|
+
const rehydrated = await call(enReader, 'scout_report', { caseId: 'rehy' }, execFor())
|
|
1352
|
+
assert.equal(CJK.test(rehydrated.report), false, 'a rehydrated zh case must be aligned before rendering')
|
|
1353
|
+
assert.match(rehydrated.report, /^- Objective: /m)
|
|
1354
|
+
|
|
1355
|
+
// --- round trip zh -> en -> zh restores the original factory prose ---------
|
|
1356
|
+
const fs4 = new MemoryFs()
|
|
1357
|
+
const first = createHarness({ scoutDir: '/rt', services: { fs: fs4 } })
|
|
1358
|
+
await call(first, 'scout_start', { caseId: 'rt', companyName: 'RT Co', roleTitle: 'HR Head', location: '' }, execFor())
|
|
1359
|
+
await call(first, 'scout_export', { caseId: 'rt', targetDir: '/rt/1' }, execFor())
|
|
1360
|
+
const middle = createHarness({ config: { language: 'en', scoutDir: '/rt' }, services: { fs: fs4 } })
|
|
1361
|
+
await call(middle, 'scout_import', { caseId: 'rt', sourceDir: '/rt/1' }, execFor())
|
|
1362
|
+
await call(middle, 'scout_export', { caseId: 'rt', targetDir: '/rt/2' }, execFor())
|
|
1363
|
+
const last = createHarness({ scoutDir: '/rt', services: { fs: fs4 } })
|
|
1364
|
+
await call(last, 'scout_import', { caseId: 'rt', sourceDir: '/rt/2' }, execFor())
|
|
1365
|
+
await call(last, 'scout_export', { caseId: 'rt', targetDir: '/rt/3' }, execFor())
|
|
1366
|
+
const restored = JSON.parse(fs4.read('/rt/3/case.json'))
|
|
1367
|
+
assert.equal(restored.language, 'zh-CN')
|
|
1368
|
+
assert.equal(restored.decisionObjective, zhFactory.decisionObjective)
|
|
1369
|
+
assert.deepEqual(restored.interviewQuestions, zhFactory.interviewQuestions)
|
|
1370
|
+
assert.equal(restored.role.fitQuestion, zhFactory.fitQuestion)
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
test('user-edited case prose survives a language change while untouched fields still align', async () => {
|
|
1374
|
+
// The complement of the test above: alignment must never overwrite what the
|
|
1375
|
+
// user wrote. Each field is decided independently, so editing the objective
|
|
1376
|
+
// does not freeze the questions (and the other way round).
|
|
1377
|
+
const enFactory = scoutStrings('en').defaults
|
|
1378
|
+
const zhFactory = scoutStrings('zh-CN').defaults
|
|
1379
|
+
const editAndMove = async (edit, caseId) => {
|
|
1380
|
+
const fs = new MemoryFs()
|
|
1381
|
+
const zh = createHarness({ scoutDir: '/u', services: { fs } })
|
|
1382
|
+
const exec = execFor()
|
|
1383
|
+
await call(zh, 'scout_start', { caseId, companyName: 'User Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1384
|
+
await call(zh, 'scout_export', { caseId, targetDir: `/u/${caseId}` }, exec)
|
|
1385
|
+
const edited = JSON.parse(fs.read(`/u/${caseId}/case.json`))
|
|
1386
|
+
edit(edited)
|
|
1387
|
+
fs.files.set(`/u/${caseId}/case.json`, `${JSON.stringify(edited, null, 2)}\n`)
|
|
1388
|
+
const en = createHarness({ config: { language: 'en', scoutDir: '/u2' }, services: { fs } })
|
|
1389
|
+
await call(en, 'scout_import', { caseId, sourceDir: `/u/${caseId}` }, exec)
|
|
1390
|
+
const report = await call(en, 'scout_report', { caseId }, exec)
|
|
1391
|
+
await call(en, 'scout_export', { caseId, targetDir: `/u/${caseId}-en` }, exec)
|
|
1392
|
+
return { reportText: report.report, caseJson: JSON.parse(fs.read(`/u/${caseId}-en/case.json`)) }
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const customObjective = '用户自定义目标(勿改)'
|
|
1396
|
+
const objectiveEdited = await editAndMove(caseJson => { caseJson.decisionObjective = customObjective }, 'obj')
|
|
1397
|
+
assert.equal(objectiveEdited.caseJson.decisionObjective, customObjective, 'the edited objective is preserved verbatim')
|
|
1398
|
+
assert.match(objectiveEdited.reportText, /^- Objective: 用户自定义目标(勿改)$/m)
|
|
1399
|
+
assert.deepEqual(
|
|
1400
|
+
objectiveEdited.caseJson.interviewQuestions,
|
|
1401
|
+
enFactory.interviewQuestions,
|
|
1402
|
+
'the untouched question set still aligns',
|
|
1403
|
+
)
|
|
1404
|
+
assert.equal(objectiveEdited.caseJson.role.fitQuestion, enFactory.fitQuestion)
|
|
1405
|
+
|
|
1406
|
+
const customFit = 'Custom fit question from the user'
|
|
1407
|
+
const fitEdited = await editAndMove(caseJson => { caseJson.role.fitQuestion = customFit }, 'fit')
|
|
1408
|
+
assert.equal(fitEdited.caseJson.role.fitQuestion, customFit)
|
|
1409
|
+
assert.equal(fitEdited.caseJson.decisionObjective, enFactory.decisionObjective, 'the objective still aligns')
|
|
1410
|
+
assert.deepEqual(fitEdited.caseJson.interviewQuestions, enFactory.interviewQuestions)
|
|
1411
|
+
|
|
1412
|
+
// A fully user-authored question set is preserved as authored — in whatever
|
|
1413
|
+
// language the user wrote it.
|
|
1414
|
+
const customQuestions = ['Q1?', 'Q2?', 'Q3?', 'Q4?', 'Q5?']
|
|
1415
|
+
const questionsEdited = await editAndMove(caseJson => { caseJson.interviewQuestions = customQuestions }, 'qs')
|
|
1416
|
+
assert.deepEqual(questionsEdited.caseJson.interviewQuestions, customQuestions)
|
|
1417
|
+
assert.match(questionsEdited.reportText, /- Q1\?/, 'and the report renders them')
|
|
1418
|
+
assert.ok(!questionsEdited.reportText.includes(zhFactory.interviewQuestions[0]))
|
|
1419
|
+
|
|
1420
|
+
// Sanity: nothing here is a factory string, so nothing may be swapped back.
|
|
1421
|
+
assert.notDeepEqual(questionsEdited.caseJson.interviewQuestions, enFactory.interviewQuestions)
|
|
1422
|
+
})
|
|
1423
|
+
|
|
1424
|
+
/* ------------------------------------------------------------------ *
|
|
1425
|
+
* 10b. F1 — curated questions align entry by entry (D10, closed)
|
|
1426
|
+
*
|
|
1427
|
+
* Round 2 replaced the all-or-nothing rule with an index-wise one: when the
|
|
1428
|
+
* curated set still has the factory length, every entry that equals its factory
|
|
1429
|
+
* slot is aligned while an edited entry is kept verbatim; a set of a different
|
|
1430
|
+
* length is preserved as a whole. All seven rows of the behaviour matrix are
|
|
1431
|
+
* asserted below as the positive contract — no test here pins a defect.
|
|
1432
|
+
* ------------------------------------------------------------------ */
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Factory defaults per language. Every assertion in this section reads the
|
|
1436
|
+
* shipped i18n table, so wording changes in `src/i18n.ts` can never make a test
|
|
1437
|
+
* pass or fail for the wrong reason.
|
|
1438
|
+
*/
|
|
1439
|
+
const FACTORY = {
|
|
1440
|
+
'zh-CN': scoutStrings('zh-CN').defaults,
|
|
1441
|
+
en: scoutStrings('en').defaults,
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** `scout_import` recomputes the decision on the way in; no other key may move. */
|
|
1445
|
+
const IMPORT_RECOMPUTED_KEYS = ['decision', 'decisionReason', 'decisionReasonCode']
|
|
1446
|
+
|
|
1447
|
+
const withoutRecomputed = caseJson => Object.fromEntries(
|
|
1448
|
+
Object.entries(caseJson).filter(([key]) => !IMPORT_RECOMPUTED_KEYS.includes(key)),
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Drive one case across a language boundary: create it in `from`, optionally
|
|
1453
|
+
* edit the exported `case.json`, then import that export into a deployment
|
|
1454
|
+
* rendering `to`. Returns the case.json that was imported, the case.json the
|
|
1455
|
+
* target deployment re-exported, and the report it rendered.
|
|
1456
|
+
*/
|
|
1457
|
+
async function crossLanguageImport({ caseId, from, to, edit, fs = new MemoryFs() }) {
|
|
1458
|
+
const exec = execFor()
|
|
1459
|
+
const sourceDir = `/matrix/${caseId}/from`
|
|
1460
|
+
const targetDir = `/matrix/${caseId}/to`
|
|
1461
|
+
const scoutDir = `/matrix/${caseId}`
|
|
1462
|
+
const writer = createHarness({ config: { language: from, scoutDir }, services: { fs } })
|
|
1463
|
+
await call(writer, 'scout_start', { caseId, companyName: 'Matrix Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1464
|
+
await call(writer, 'scout_export', { caseId, targetDir: sourceDir }, exec)
|
|
1465
|
+
const imported = JSON.parse(fs.read(`${sourceDir}/case.json`))
|
|
1466
|
+
if (edit) {
|
|
1467
|
+
edit(imported)
|
|
1468
|
+
fs.files.set(`${sourceDir}/case.json`, `${JSON.stringify(imported, null, 2)}\n`)
|
|
1469
|
+
}
|
|
1470
|
+
const reader = createHarness({ config: { language: to, scoutDir }, services: { fs } })
|
|
1471
|
+
await call(reader, 'scout_import', { caseId, sourceDir }, exec)
|
|
1472
|
+
const report = await call(reader, 'scout_report', { caseId }, exec)
|
|
1473
|
+
await call(reader, 'scout_export', { caseId, targetDir }, exec)
|
|
1474
|
+
return { imported, exported: JSON.parse(fs.read(`${targetDir}/case.json`)), report: report.report, fs }
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
test('F1 matrix 1/7 · an untouched factory question set aligns as a whole in an en deployment', async () => {
|
|
1478
|
+
const moved = await crossLanguageImport({ caseId: 'm1', from: 'zh-CN', to: 'en' })
|
|
1479
|
+
assert.deepEqual(
|
|
1480
|
+
moved.imported.interviewQuestions,
|
|
1481
|
+
FACTORY['zh-CN'].interviewQuestions,
|
|
1482
|
+
'the source really carried the untouched zh factory set',
|
|
1483
|
+
)
|
|
1484
|
+
assert.deepEqual(moved.exported.interviewQuestions, FACTORY.en.interviewQuestions)
|
|
1485
|
+
assert.equal(moved.exported.language, 'en')
|
|
1486
|
+
assert.equal(countCjk(moved.report), 0, 'an aligned en report carries no CJK at all')
|
|
1487
|
+
})
|
|
1488
|
+
|
|
1489
|
+
test('F1 matrix 2/7 · D10 (closed): one edited entry is kept, the rest align, and the en report CJK equals that entry', async () => {
|
|
1490
|
+
const userQuestion = '我自己写的第三个问题?'
|
|
1491
|
+
const moved = await crossLanguageImport({
|
|
1492
|
+
caseId: 'm2',
|
|
1493
|
+
from: 'zh-CN',
|
|
1494
|
+
to: 'en',
|
|
1495
|
+
edit: caseJson => { caseJson.interviewQuestions[2] = userQuestion },
|
|
1496
|
+
})
|
|
1497
|
+
assert.equal(moved.exported.interviewQuestions[2], userQuestion, 'the edited entry is preserved verbatim')
|
|
1498
|
+
assert.deepEqual(
|
|
1499
|
+
moved.exported.interviewQuestions,
|
|
1500
|
+
FACTORY.en.interviewQuestions.map((question, index) => (index === 2 ? userQuestion : question)),
|
|
1501
|
+
'every untouched entry aligns to this deployment’s factory default',
|
|
1502
|
+
)
|
|
1503
|
+
assert.ok(moved.report.includes(userQuestion), 'the report renders the user’s own question')
|
|
1504
|
+
assert.ok(countCjk(userQuestion) > 0, 'the count assertion below is only meaningful if the user wrote CJK')
|
|
1505
|
+
assert.equal(
|
|
1506
|
+
countCjk(moved.report),
|
|
1507
|
+
countCjk(userQuestion),
|
|
1508
|
+
'the only CJK left in the en report is the user’s own question, not four frozen factory entries',
|
|
1509
|
+
)
|
|
1510
|
+
})
|
|
1511
|
+
|
|
1512
|
+
test('F1 matrix 3/7 · a question set of a different length is preserved as a whole', async () => {
|
|
1513
|
+
const custom = ['自定义问题一?', '自定义问题二?', '自定义问题三?']
|
|
1514
|
+
const moved = await crossLanguageImport({
|
|
1515
|
+
caseId: 'm3',
|
|
1516
|
+
from: 'zh-CN',
|
|
1517
|
+
to: 'en',
|
|
1518
|
+
edit: caseJson => { caseJson.interviewQuestions = custom },
|
|
1519
|
+
})
|
|
1520
|
+
assert.equal(custom.length === FACTORY['zh-CN'].interviewQuestions.length, false, 'this row needs a different length')
|
|
1521
|
+
assert.deepEqual(moved.exported.interviewQuestions, custom)
|
|
1522
|
+
assert.ok(moved.report.includes(custom[0]), 'the report renders them as authored')
|
|
1523
|
+
assert.equal(
|
|
1524
|
+
moved.report.includes(FACTORY.en.interviewQuestions[0]),
|
|
1525
|
+
false,
|
|
1526
|
+
'no factory entry may be injected into a differently sized set',
|
|
1527
|
+
)
|
|
1528
|
+
assert.equal(
|
|
1529
|
+
countCjk(moved.report),
|
|
1530
|
+
custom.reduce((total, question) => total + countCjk(question), 0),
|
|
1531
|
+
'a differently sized set is kept whole, so its CJK is exactly what the user wrote',
|
|
1532
|
+
)
|
|
1533
|
+
})
|
|
1534
|
+
|
|
1535
|
+
test('F1 matrix 4/7 · a same-length set with no factory entry left is equivalent to a whole-set replacement', async () => {
|
|
1536
|
+
const custom = ['A1?', 'A2?', 'A3?', 'A4?', 'A5?']
|
|
1537
|
+
const moved = await crossLanguageImport({
|
|
1538
|
+
caseId: 'm4',
|
|
1539
|
+
from: 'zh-CN',
|
|
1540
|
+
to: 'en',
|
|
1541
|
+
edit: caseJson => { caseJson.interviewQuestions = custom },
|
|
1542
|
+
})
|
|
1543
|
+
assert.equal(
|
|
1544
|
+
custom.length,
|
|
1545
|
+
FACTORY['zh-CN'].interviewQuestions.length,
|
|
1546
|
+
'the set has the factory length, so the entry-by-entry branch runs',
|
|
1547
|
+
)
|
|
1548
|
+
assert.deepEqual(moved.exported.interviewQuestions, custom, 'no entry matches its slot, so nothing is swapped')
|
|
1549
|
+
for (const question of custom) {
|
|
1550
|
+
assert.ok(moved.report.includes(`- ${question}`), `the report must render the user’s ${question}`)
|
|
1551
|
+
}
|
|
1552
|
+
assert.equal(
|
|
1553
|
+
FACTORY.en.interviewQuestions.some(question => moved.report.includes(question)),
|
|
1554
|
+
false,
|
|
1555
|
+
'no factory entry may be reintroduced into a fully custom set',
|
|
1556
|
+
)
|
|
1557
|
+
})
|
|
1558
|
+
|
|
1559
|
+
test('F1 matrix 5/7 · the rule is symmetric: an en case aligns entry by entry into zh-CN', async () => {
|
|
1560
|
+
const whole = await crossLanguageImport({ caseId: 'm5a', from: 'en', to: 'zh-CN' })
|
|
1561
|
+
assert.deepEqual(whole.imported.interviewQuestions, FACTORY.en.interviewQuestions)
|
|
1562
|
+
assert.deepEqual(whole.exported.interviewQuestions, FACTORY['zh-CN'].interviewQuestions)
|
|
1563
|
+
assert.equal(whole.exported.language, 'zh-CN')
|
|
1564
|
+
|
|
1565
|
+
const userQuestion = 'My own third question?'
|
|
1566
|
+
const partial = await crossLanguageImport({
|
|
1567
|
+
caseId: 'm5b',
|
|
1568
|
+
from: 'en',
|
|
1569
|
+
to: 'zh-CN',
|
|
1570
|
+
edit: caseJson => { caseJson.interviewQuestions[2] = userQuestion },
|
|
1571
|
+
})
|
|
1572
|
+
assert.deepEqual(
|
|
1573
|
+
partial.exported.interviewQuestions,
|
|
1574
|
+
FACTORY['zh-CN'].interviewQuestions.map((question, index) => (index === 2 ? userQuestion : question)),
|
|
1575
|
+
'the mirror image of row 2 holds with en as the source language',
|
|
1576
|
+
)
|
|
1577
|
+
})
|
|
1578
|
+
|
|
1579
|
+
test('F1 matrix 6/7 · a zh → en → zh round trip restores every factory entry', async () => {
|
|
1580
|
+
const exec = execFor()
|
|
1581
|
+
const hop = async (fs, language, caseId, sourceDir, targetDir) => {
|
|
1582
|
+
const harness = createHarness({ config: { language, scoutDir: `/round/${caseId}` }, services: { fs } })
|
|
1583
|
+
if (sourceDir) {
|
|
1584
|
+
await call(harness, 'scout_import', { caseId, sourceDir }, exec)
|
|
1585
|
+
} else {
|
|
1586
|
+
await call(harness, 'scout_start', { caseId, companyName: 'Round Trip Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1587
|
+
}
|
|
1588
|
+
await call(harness, 'scout_export', { caseId, targetDir }, exec)
|
|
1589
|
+
return JSON.parse(fs.read(`${targetDir}/case.json`))
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const fs = new MemoryFs()
|
|
1593
|
+
await hop(fs, 'zh-CN', 'm6', null, '/round/m6/1')
|
|
1594
|
+
const middle = await hop(fs, 'en', 'm6', '/round/m6/1', '/round/m6/2')
|
|
1595
|
+
assert.equal(middle.language, 'en')
|
|
1596
|
+
assert.deepEqual(middle.interviewQuestions, FACTORY.en.interviewQuestions)
|
|
1597
|
+
const restored = await hop(fs, 'zh-CN', 'm6', '/round/m6/2', '/round/m6/3')
|
|
1598
|
+
assert.equal(restored.language, 'zh-CN')
|
|
1599
|
+
assert.deepEqual(restored.interviewQuestions, FACTORY['zh-CN'].interviewQuestions)
|
|
1600
|
+
|
|
1601
|
+
// The same trip with one entry edited: that entry survives both hops while
|
|
1602
|
+
// every other entry comes home to the zh factory value, slot by slot.
|
|
1603
|
+
const fs2 = new MemoryFs()
|
|
1604
|
+
const userQuestion = '只有这一条是用户改的?'
|
|
1605
|
+
await hop(fs2, 'zh-CN', 'm6b', null, '/round/m6b/1')
|
|
1606
|
+
const seeded = JSON.parse(fs2.read('/round/m6b/1/case.json'))
|
|
1607
|
+
seeded.interviewQuestions[2] = userQuestion
|
|
1608
|
+
fs2.files.set('/round/m6b/1/case.json', `${JSON.stringify(seeded, null, 2)}\n`)
|
|
1609
|
+
const middleB = await hop(fs2, 'en', 'm6b', '/round/m6b/1', '/round/m6b/2')
|
|
1610
|
+
assert.equal(middleB.interviewQuestions[2], userQuestion)
|
|
1611
|
+
assert.deepEqual(middleB.interviewQuestions.slice(0, 2), FACTORY.en.interviewQuestions.slice(0, 2))
|
|
1612
|
+
assert.deepEqual(middleB.interviewQuestions.slice(3), FACTORY.en.interviewQuestions.slice(3))
|
|
1613
|
+
const restoredB = await hop(fs2, 'zh-CN', 'm6b', '/round/m6b/2', '/round/m6b/3')
|
|
1614
|
+
assert.equal(restoredB.interviewQuestions[2], userQuestion, 'the user’s edit outlives both hops')
|
|
1615
|
+
assert.deepEqual(
|
|
1616
|
+
restoredB.interviewQuestions.filter((_, index) => index !== 2),
|
|
1617
|
+
FACTORY['zh-CN'].interviewQuestions.filter((_, index) => index !== 2),
|
|
1618
|
+
'and every other slot returns to the zh factory value',
|
|
1619
|
+
)
|
|
1620
|
+
})
|
|
1621
|
+
|
|
1622
|
+
test('F1 matrix 7/7 · importing a case into its own language rewrites no case-authored field', async () => {
|
|
1623
|
+
const moved = await crossLanguageImport({ caseId: 'm7', from: 'zh-CN', to: 'zh-CN' })
|
|
1624
|
+
assert.equal(moved.imported.language, 'zh-CN')
|
|
1625
|
+
assert.deepEqual(
|
|
1626
|
+
withoutRecomputed(moved.exported),
|
|
1627
|
+
withoutRecomputed(moved.imported),
|
|
1628
|
+
'apart from the decision scout_import recomputes, case.json is field-for-field unchanged',
|
|
1629
|
+
)
|
|
1630
|
+
for (const field of ['language', 'title', 'decisionObjective', 'subject', 'role', 'interviewQuestions']) {
|
|
1631
|
+
assert.deepEqual(moved.exported[field], moved.imported[field], `${field} must survive a same-language import verbatim`)
|
|
1632
|
+
}
|
|
1633
|
+
assert.deepEqual(moved.exported.interviewQuestions, FACTORY['zh-CN'].interviewQuestions)
|
|
1634
|
+
assert.equal(moved.exported.decision, moved.imported.decision, 'the recomputed decision agrees with the imported one')
|
|
1635
|
+
})
|
|
1636
|
+
|
|
1637
|
+
/* ------------------------------------------------------------------ *
|
|
1638
|
+
* 11. Explicit host entry points
|
|
1639
|
+
* ------------------------------------------------------------------ */
|
|
1640
|
+
|
|
1641
|
+
test('the /scout command exposes four working sub-actions and the scout Skill carries the evidence disciplines', async () => {
|
|
1642
|
+
const commands = []
|
|
1643
|
+
const skills = []
|
|
1644
|
+
const fs = new MemoryFs()
|
|
1645
|
+
const harness = createHarness()
|
|
1646
|
+
|
|
1647
|
+
// Both services arrive after apply(), exercising the deferred inject path.
|
|
1648
|
+
harness.ctx.mount('commands', { register: definition => { commands.push(definition); return () => {} } })
|
|
1649
|
+
harness.ctx.mount('skills', { register: definition => { skills.push(definition); return () => {} } })
|
|
1650
|
+
harness.ctx.mount('fs', fs)
|
|
1651
|
+
|
|
1652
|
+
assert.equal(commands.length, 1)
|
|
1653
|
+
assert.equal(commands[0].name, 'scout')
|
|
1654
|
+
assert.equal(skills[0].name, 'scout')
|
|
1655
|
+
assert.equal(skills[0].invocation.modelInvocable, true)
|
|
1656
|
+
assert.equal(skills[0].invocation.userInvocable, true)
|
|
1657
|
+
assert.match(skills[0].content, /Evidence discipline/)
|
|
1658
|
+
assert.match(skills[0].content, /scout_verify_identity/)
|
|
1659
|
+
assert.equal((skills[0].content.match(/^\d+\. /gm) ?? []).length >= 4, true)
|
|
1660
|
+
|
|
1661
|
+
const exec = execFor('cmd-session')
|
|
1662
|
+
await call(harness, 'scout_start', { caseId: 'cmd', companyName: 'Command Co', roleTitle: 'HR Head', location: '' }, exec)
|
|
1663
|
+
const invoke = rawInput => commands[0].handler({
|
|
1664
|
+
rawInput,
|
|
1665
|
+
agent: { id: 'cmd-session', session: { header: { cwd: '/session/cwd' } } },
|
|
1666
|
+
})
|
|
1667
|
+
|
|
1668
|
+
assert.match((await invoke('')).text, /^usage: \/scout status/, 'bare /scout prints usage')
|
|
1669
|
+
assert.match((await invoke('help')).text, /^usage: \/scout status/)
|
|
1670
|
+
assert.match((await invoke('status cmd')).text, /^\[cmd\] VERIFY · identity needs_verification/)
|
|
1671
|
+
assert.match((await invoke('questions cmd')).text, /^1\. /)
|
|
1672
|
+
assert.match((await invoke('report cmd')).text, /^# Command Co — HR Head$/m)
|
|
1673
|
+
const exported = await invoke('export cmd')
|
|
1674
|
+
assert.equal(exported.kind, 'success')
|
|
1675
|
+
assert.match(exported.text, /^exported to dsh-scout\/cmd$/)
|
|
1676
|
+
for (const fileName of [...EXPORT_FILE_NAMES, EXPORT_MARKER_FILE]) {
|
|
1677
|
+
assert.ok(fs.has(`dsh-scout/cmd/${fileName}`), `/scout export is missing ${fileName}`)
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
assert.equal((await invoke('bogus cmd')).kind, 'error')
|
|
1681
|
+
assert.match((await invoke('bogus cmd')).text, /Unknown \/scout action: bogus/)
|
|
1682
|
+
assert.equal((await invoke('status missing')).kind, 'error')
|
|
1683
|
+
assert.match((await invoke('status missing')).text, /Unknown case: missing/)
|
|
1684
|
+
})
|