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,974 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import { apply, Config, inject, name } from '../dist/index.js'
|
|
5
|
+
import {
|
|
6
|
+
createCase,
|
|
7
|
+
exportCaseFiles,
|
|
8
|
+
inferEvidenceLevel,
|
|
9
|
+
inferSourceType,
|
|
10
|
+
isTrustedAuthorityUrl,
|
|
11
|
+
} from '../dist/model.js'
|
|
12
|
+
|
|
13
|
+
/* ------------------------------------------------------------------ *
|
|
14
|
+
* Harness
|
|
15
|
+
* ------------------------------------------------------------------ */
|
|
16
|
+
|
|
17
|
+
const ALL_TOOLS = [
|
|
18
|
+
'scout_add_claim',
|
|
19
|
+
'scout_add_source',
|
|
20
|
+
'scout_compare',
|
|
21
|
+
'scout_export',
|
|
22
|
+
'scout_import',
|
|
23
|
+
'scout_ingest',
|
|
24
|
+
'scout_questions',
|
|
25
|
+
'scout_report',
|
|
26
|
+
'scout_search',
|
|
27
|
+
'scout_start',
|
|
28
|
+
'scout_verify_claim',
|
|
29
|
+
'scout_verify_identity',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const execFor = (sessionId = 'session-a') => ({ agent: { id: sessionId } })
|
|
33
|
+
|
|
34
|
+
/** In-memory fs double keyed by display path. */
|
|
35
|
+
function mockFs(store = new Map(), options = {}) {
|
|
36
|
+
return {
|
|
37
|
+
store,
|
|
38
|
+
async resolve(path) {
|
|
39
|
+
return { targetKey: path, displayPath: path }
|
|
40
|
+
},
|
|
41
|
+
async readText(target) {
|
|
42
|
+
if (!store.has(target.displayPath)) throw new Error(`not found: ${target.displayPath}`)
|
|
43
|
+
return store.get(target.displayPath)
|
|
44
|
+
},
|
|
45
|
+
async writeText(target, content) {
|
|
46
|
+
if (options.failWrites) throw new Error(options.failWrites)
|
|
47
|
+
store.set(target.displayPath, content)
|
|
48
|
+
return { operation: 'create' }
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mount the plugin with the minimal Context surface it touches. `ctx.get`
|
|
55
|
+
* reads the live object, so a service can be attached after `apply()` to
|
|
56
|
+
* exercise call-time service resolution.
|
|
57
|
+
*/
|
|
58
|
+
function mountPlugin({ config, services = {} } = {}) {
|
|
59
|
+
const registered = []
|
|
60
|
+
const disposed = []
|
|
61
|
+
const effects = []
|
|
62
|
+
const injections = []
|
|
63
|
+
// A real ctx.effect accepts either a generator of disposers (as the tool
|
|
64
|
+
// registration uses) or a callback returning one disposer.
|
|
65
|
+
const collectEffects = (execute) => {
|
|
66
|
+
const result = execute()
|
|
67
|
+
if (result && typeof result[Symbol.iterator] === 'function') effects.push(...result)
|
|
68
|
+
else if (typeof result === 'function') effects.push(result)
|
|
69
|
+
}
|
|
70
|
+
const ctx = {
|
|
71
|
+
get(serviceName) {
|
|
72
|
+
return this[serviceName]
|
|
73
|
+
},
|
|
74
|
+
tools: {
|
|
75
|
+
register(tool) {
|
|
76
|
+
registered.push(tool)
|
|
77
|
+
return () => disposed.push(tool.name)
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
effect: collectEffects,
|
|
81
|
+
inject(dependencies, callback) {
|
|
82
|
+
injections.push([...dependencies, callback])
|
|
83
|
+
// Mirror the real binding: the callback runs against a child context that
|
|
84
|
+
// sees the injected services and owns its own effect scope. Without this
|
|
85
|
+
// the optional-seam registrations (command, Skill) would never run here.
|
|
86
|
+
const child = {
|
|
87
|
+
get(serviceName) {
|
|
88
|
+
return this[serviceName]
|
|
89
|
+
},
|
|
90
|
+
effect: collectEffects,
|
|
91
|
+
}
|
|
92
|
+
for (const dependency of dependencies) {
|
|
93
|
+
if (dependency in ctx) child[dependency] = ctx[dependency]
|
|
94
|
+
}
|
|
95
|
+
callback(child)
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
Object.assign(ctx, services)
|
|
99
|
+
apply(ctx, config)
|
|
100
|
+
return {
|
|
101
|
+
ctx,
|
|
102
|
+
registered,
|
|
103
|
+
disposed,
|
|
104
|
+
effects,
|
|
105
|
+
injections,
|
|
106
|
+
tools: new Map(registered.map(tool => [tool.name, tool])),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Assert a canonical value satisfies the tool's own declared output schema. */
|
|
111
|
+
function assertSchemaConforms(mounted, toolName, value) {
|
|
112
|
+
const tool = mounted.tools.get(toolName)
|
|
113
|
+
const violations = validateJsonSchemaValue(tool.output.schema, value)
|
|
114
|
+
assert.deepEqual(violations, [], `${toolName} value violates its declared schema`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const renderedText = (blocks) => blocks.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
118
|
+
const outputBytes = (blocks) => Buffer.byteLength(renderedText(blocks), 'utf8')
|
|
119
|
+
const renderOutput = (mounted, toolName, value) => mounted.tools.get(toolName).output.render({}, value)
|
|
120
|
+
|
|
121
|
+
/* ------------------------------------------------------------------ *
|
|
122
|
+
* Tool surface
|
|
123
|
+
* ------------------------------------------------------------------ */
|
|
124
|
+
|
|
125
|
+
test('exports a disposable DSH plugin tool surface', async () => {
|
|
126
|
+
const mounted = mountPlugin()
|
|
127
|
+
assert.equal(name, 'dsh-scout')
|
|
128
|
+
assert.deepEqual(inject, ['tools'])
|
|
129
|
+
assert.deepEqual(mounted.registered.map(tool => tool.name).sort(), ALL_TOOLS)
|
|
130
|
+
for (const dispose of mounted.effects.reverse()) dispose()
|
|
131
|
+
assert.deepEqual(mounted.disposed.sort(), [...ALL_TOOLS].sort())
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('isolates identical case ids by DSH agent session identity', async () => {
|
|
135
|
+
const mounted = mountPlugin()
|
|
136
|
+
const start = mounted.tools.get('scout_start')
|
|
137
|
+
const report = mounted.tools.get('scout_report')
|
|
138
|
+
|
|
139
|
+
await start.execute({ caseId: 'same-id', companyName: 'Company A', roleTitle: 'Role A', location: '' }, execFor('session-a'))
|
|
140
|
+
await start.execute({ caseId: 'same-id', companyName: 'Company B', roleTitle: 'Role B', location: '' }, execFor('session-b'))
|
|
141
|
+
|
|
142
|
+
const reportA = (await report.execute({ caseId: 'same-id' }, execFor('session-a'))).report
|
|
143
|
+
const reportB = (await report.execute({ caseId: 'same-id' }, execFor('session-b'))).report
|
|
144
|
+
assert.match(reportA, /Company A — Role A/)
|
|
145
|
+
assert.doesNotMatch(reportA, /Company B/)
|
|
146
|
+
assert.match(reportB, /Company B — Role B/)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
/* ------------------------------------------------------------------ *
|
|
150
|
+
* Output contract
|
|
151
|
+
* ------------------------------------------------------------------ */
|
|
152
|
+
|
|
153
|
+
test('mutation tools return a schema-valid compact envelope instead of the whole case', async () => {
|
|
154
|
+
const mounted = mountPlugin()
|
|
155
|
+
const value = await mounted.tools.get('scout_start').execute({
|
|
156
|
+
caseId: 'compact',
|
|
157
|
+
companyName: 'Example Co',
|
|
158
|
+
roleTitle: 'HR Head',
|
|
159
|
+
location: 'Shenzhen',
|
|
160
|
+
}, execFor())
|
|
161
|
+
|
|
162
|
+
assertSchemaConforms(mounted, 'scout_start', value)
|
|
163
|
+
assert.deepEqual(Object.keys(value).sort(), [
|
|
164
|
+
'caseId', 'changed', 'counts', 'decision', 'identity', 'nextActions', 'ok',
|
|
165
|
+
])
|
|
166
|
+
assert.equal(value.changed.kind, 'case_started')
|
|
167
|
+
assert.equal(value.decision, 'VERIFY')
|
|
168
|
+
assert.deepEqual(value.counts, { sources: 0, claims: 0, verified: 0, open: 0 })
|
|
169
|
+
|
|
170
|
+
const content = renderOutput(mounted, 'scout_start', value)
|
|
171
|
+
assert.ok(outputBytes(content) < 1024, `render was ${outputBytes(content)} bytes`)
|
|
172
|
+
assert.doesNotMatch(renderedText(content), /"schemaVersion"/, 'must not dump the raw case JSON')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('every mutating tool renders under the 1 KB model-facing budget on a loaded case', async () => {
|
|
176
|
+
const mounted = mountPlugin()
|
|
177
|
+
await mounted.tools.get('scout_start').execute({
|
|
178
|
+
caseId: 'budget', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
179
|
+
}, execFor())
|
|
180
|
+
|
|
181
|
+
const items = []
|
|
182
|
+
for (let index = 1; index <= 12; index += 1) {
|
|
183
|
+
items.push({
|
|
184
|
+
url: `https://example.com/report-${index}`,
|
|
185
|
+
title: `Independent report ${index}`,
|
|
186
|
+
claim: { text: `Finding number ${index} about the company and the role.`, dimension: 'risk', impact: 'material' },
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
const value = await mounted.tools.get('scout_ingest').execute({
|
|
190
|
+
caseId: 'budget', itemsJson: JSON.stringify(items),
|
|
191
|
+
}, execFor())
|
|
192
|
+
|
|
193
|
+
assertSchemaConforms(mounted, 'scout_ingest', value)
|
|
194
|
+
assert.equal(value.counts.sources, 12)
|
|
195
|
+
assert.equal(value.counts.claims, 12)
|
|
196
|
+
assert.ok(value.changed.ids.length > 0)
|
|
197
|
+
assert.ok(value.nextActions.length <= 3)
|
|
198
|
+
|
|
199
|
+
const content = renderOutput(mounted, 'scout_ingest', value)
|
|
200
|
+
assert.ok(outputBytes(content) < 1024, `render was ${outputBytes(content)} bytes`)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('scout_report stays the only tool that returns the full artifact', async () => {
|
|
204
|
+
const mounted = mountPlugin()
|
|
205
|
+
await mounted.tools.get('scout_start').execute({
|
|
206
|
+
caseId: 'full', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
207
|
+
}, execFor())
|
|
208
|
+
|
|
209
|
+
const value = await mounted.tools.get('scout_report').execute({ caseId: 'full' }, execFor())
|
|
210
|
+
assertSchemaConforms(mounted, 'scout_report', value)
|
|
211
|
+
assert.equal(value.caseId, 'full')
|
|
212
|
+
assert.equal(value.decision, 'VERIFY')
|
|
213
|
+
assert.match(value.report, /# Example Co — HR Head/)
|
|
214
|
+
assert.match(value.report, /## Verification checklist/)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
/* ------------------------------------------------------------------ *
|
|
218
|
+
* Durable export
|
|
219
|
+
* ------------------------------------------------------------------ */
|
|
220
|
+
|
|
221
|
+
test('exports and imports a case through the fs-backed tools, reporting the resolved path', async () => {
|
|
222
|
+
const fs = mockFs()
|
|
223
|
+
const mounted = mountPlugin({ services: { fs } })
|
|
224
|
+
|
|
225
|
+
await mounted.tools.get('scout_start').execute({
|
|
226
|
+
caseId: 'persisted', companyName: 'Example Co', roleTitle: 'HR Head', location: 'Shenzhen',
|
|
227
|
+
}, execFor())
|
|
228
|
+
await mounted.tools.get('scout_add_source').execute({
|
|
229
|
+
caseId: 'persisted', sourceId: 'job-posting', sourceType: 'job_posting',
|
|
230
|
+
title: 'Example job posting', url: 'https://example.com/job', evidenceLevel: 'E1',
|
|
231
|
+
}, execFor())
|
|
232
|
+
await mounted.tools.get('scout_add_claim').execute({
|
|
233
|
+
caseId: 'persisted', claimId: 'claim-1', text: 'The role exists.', status: 'reported',
|
|
234
|
+
evidenceLevel: 'E1', dimension: 'role_existence', impact: 'blocking', sourceIds: 'job-posting',
|
|
235
|
+
confidenceNote: 'The job posting is a company-controlled source.',
|
|
236
|
+
nextAction: 'Confirm the mandate in the interview.',
|
|
237
|
+
}, execFor())
|
|
238
|
+
|
|
239
|
+
const exported = await mounted.tools.get('scout_export').execute({
|
|
240
|
+
caseId: 'persisted', targetDir: '/tmp/scout-cases/persisted',
|
|
241
|
+
}, execFor())
|
|
242
|
+
assertSchemaConforms(mounted, 'scout_export', exported)
|
|
243
|
+
assert.equal(exported.persisted, true)
|
|
244
|
+
assert.equal(exported.complete, true)
|
|
245
|
+
assert.equal(exported.targetDir, '/tmp/scout-cases/persisted')
|
|
246
|
+
assert.deepEqual(exported.files, [
|
|
247
|
+
'case.json', 'sources.json', 'claims.json', 'events.jsonl', 'report.md', 'export.complete.json',
|
|
248
|
+
])
|
|
249
|
+
for (const fileName of exported.files) {
|
|
250
|
+
assert.ok(fs.store.has(`/tmp/scout-cases/persisted/${fileName}`), `missing ${fileName}`)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const imported = await mounted.tools.get('scout_import').execute({
|
|
254
|
+
caseId: 'persisted', sourceDir: '/tmp/scout-cases/persisted',
|
|
255
|
+
}, execFor())
|
|
256
|
+
assertSchemaConforms(mounted, 'scout_import', imported)
|
|
257
|
+
assert.equal(imported.caseId, 'persisted')
|
|
258
|
+
assert.equal(imported.complete, true)
|
|
259
|
+
assert.equal(imported.counts.sources, 1)
|
|
260
|
+
assert.equal(imported.counts.claims, 1)
|
|
261
|
+
assert.equal(imported.decision, 'VERIFY')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('a completed export contains the case_exported event it records, and extends stably', async () => {
|
|
265
|
+
const fs = mockFs()
|
|
266
|
+
const mounted = mountPlugin({ services: { fs } })
|
|
267
|
+
await mounted.tools.get('scout_start').execute({
|
|
268
|
+
caseId: 'faithful', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
269
|
+
}, execFor())
|
|
270
|
+
|
|
271
|
+
const exportTool = mounted.tools.get('scout_export')
|
|
272
|
+
await exportTool.execute({ caseId: 'faithful', targetDir: '/tmp/faithful' }, execFor())
|
|
273
|
+
|
|
274
|
+
const firstTypes = fs.store.get('/tmp/faithful/events.jsonl').trim().split('\n').map(line => JSON.parse(line).type)
|
|
275
|
+
assert.deepEqual(firstTypes, ['case_started', 'case_exported'])
|
|
276
|
+
|
|
277
|
+
await mounted.tools.get('scout_import').execute({ caseId: 'faithful', sourceDir: '/tmp/faithful' }, execFor())
|
|
278
|
+
await exportTool.execute({ caseId: 'faithful', targetDir: '/tmp/faithful' }, execFor())
|
|
279
|
+
|
|
280
|
+
const secondTypes = fs.store.get('/tmp/faithful/events.jsonl').trim().split('\n').map(line => JSON.parse(line).type)
|
|
281
|
+
assert.deepEqual(secondTypes, ['case_started', 'case_exported', 'case_imported', 'case_exported'])
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('an older export without a completion marker still imports but reports complete:false', async () => {
|
|
285
|
+
const store = new Map()
|
|
286
|
+
const legacy = exportCaseFiles(createCase({ caseId: 'legacy', companyName: 'Legacy Co', roleTitle: 'HR Head' }))
|
|
287
|
+
for (const [fileName, content] of Object.entries(legacy)) {
|
|
288
|
+
store.set(`/tmp/legacy/${fileName}`, content)
|
|
289
|
+
}
|
|
290
|
+
const mounted = mountPlugin({ services: { fs: mockFs(store) } })
|
|
291
|
+
const imported = await mounted.tools.get('scout_import').execute({
|
|
292
|
+
caseId: 'legacy', sourceDir: '/tmp/legacy',
|
|
293
|
+
}, execFor())
|
|
294
|
+
assert.equal(imported.complete, false)
|
|
295
|
+
assert.equal(imported.caseId, 'legacy')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
/* ------------------------------------------------------------------ *
|
|
299
|
+
* autoPersist
|
|
300
|
+
* ------------------------------------------------------------------ */
|
|
301
|
+
|
|
302
|
+
test('auto-persist reports success with the resolved directory on every mutation', async () => {
|
|
303
|
+
const fs = mockFs()
|
|
304
|
+
const mounted = mountPlugin({ config: { scoutDir: '/tmp/scout-cases', autoPersist: true }, services: { fs } })
|
|
305
|
+
const started = await mounted.tools.get('scout_start').execute({
|
|
306
|
+
caseId: 'auto', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
307
|
+
}, execFor())
|
|
308
|
+
|
|
309
|
+
assertSchemaConforms(mounted, 'scout_start', started)
|
|
310
|
+
assert.deepEqual(started.persist, { persisted: true, targetDir: '/tmp/scout-cases/auto' })
|
|
311
|
+
assert.ok(fs.store.has('/tmp/scout-cases/auto/case.json'))
|
|
312
|
+
assert.ok(fs.store.has('/tmp/scout-cases/auto/export.complete.json'))
|
|
313
|
+
assert.match(fs.store.get('/tmp/scout-cases/auto/events.jsonl'), /"type":"case_exported"/)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('auto-persist failure is surfaced instead of silently swallowed', async () => {
|
|
317
|
+
const fs = mockFs(new Map(), { failWrites: 'disk full' })
|
|
318
|
+
const mounted = mountPlugin({ config: { scoutDir: '/tmp/scout-cases', autoPersist: true }, services: { fs } })
|
|
319
|
+
const started = await mounted.tools.get('scout_start').execute({
|
|
320
|
+
caseId: 'failing', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
321
|
+
}, execFor())
|
|
322
|
+
|
|
323
|
+
assertSchemaConforms(mounted, 'scout_start', started)
|
|
324
|
+
assert.equal(started.persist.persisted, false)
|
|
325
|
+
assert.match(started.persist.error, /disk full/)
|
|
326
|
+
assert.match(renderedText(renderOutput(mounted, 'scout_start', started)), /PERSIST FAILED/)
|
|
327
|
+
|
|
328
|
+
// A failed auto-persist must not claim that an export happened.
|
|
329
|
+
const report = await mounted.tools.get('scout_report').execute({ caseId: 'failing' }, execFor())
|
|
330
|
+
assert.match(report.report, /# Example Co — HR Head/)
|
|
331
|
+
const exported = await mounted.tools.get('scout_export').execute({
|
|
332
|
+
caseId: 'failing', targetDir: '/tmp/nope',
|
|
333
|
+
}, execFor())
|
|
334
|
+
assert.equal(exported.persisted, false)
|
|
335
|
+
assert.equal(exported.complete, false)
|
|
336
|
+
assert.match(exported.error, /disk full/)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('auto-persist without a filesystem service degrades gracefully', async () => {
|
|
340
|
+
const mounted = mountPlugin({ config: { scoutDir: '/tmp/scout-cases', autoPersist: true } })
|
|
341
|
+
const started = await mounted.tools.get('scout_start').execute({
|
|
342
|
+
caseId: 'no-fs', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
343
|
+
}, execFor())
|
|
344
|
+
assert.equal(started.persist.persisted, false)
|
|
345
|
+
assert.match(started.persist.error, /Filesystem service is unavailable/)
|
|
346
|
+
const report = await mounted.tools.get('scout_report').execute({ caseId: 'no-fs' }, execFor())
|
|
347
|
+
assert.match(report.report, /# Example Co — HR Head/)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
/* ------------------------------------------------------------------ *
|
|
351
|
+
* Late binding + rehydration
|
|
352
|
+
* ------------------------------------------------------------------ */
|
|
353
|
+
|
|
354
|
+
test('optional services are resolved per call, not snapshotted at apply time', async () => {
|
|
355
|
+
const mounted = mountPlugin()
|
|
356
|
+
await mounted.tools.get('scout_start').execute({
|
|
357
|
+
caseId: 'late', companyName: 'Late Co', roleTitle: 'HR Head', location: '',
|
|
358
|
+
}, execFor())
|
|
359
|
+
|
|
360
|
+
// Both optional services appear only after the plugin was applied.
|
|
361
|
+
mounted.ctx.web = {
|
|
362
|
+
async search() {
|
|
363
|
+
return { sources: [{ url: 'https://example.com/late', title: 'Late result' }], truncated: false }
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
const found = await mounted.tools.get('scout_search').execute({ caseId: 'late', query: 'late' }, execFor())
|
|
367
|
+
assert.equal(found.counts.sources, 1)
|
|
368
|
+
|
|
369
|
+
mounted.ctx.fs = mockFs()
|
|
370
|
+
const exported = await mounted.tools.get('scout_export').execute({
|
|
371
|
+
caseId: 'late', targetDir: '/tmp/late',
|
|
372
|
+
}, execFor())
|
|
373
|
+
assert.equal(exported.persisted, true)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('a cache miss rehydrates the case from the export directory and records it', async () => {
|
|
377
|
+
const store = new Map()
|
|
378
|
+
const files = exportCaseFiles(createCase({ caseId: 'restored', companyName: 'Restored Co', roleTitle: 'HR Head' }))
|
|
379
|
+
for (const [fileName, content] of Object.entries(files)) {
|
|
380
|
+
store.set(`/tmp/scout-cases/restored/${fileName}`, content)
|
|
381
|
+
}
|
|
382
|
+
store.set('/tmp/scout-cases/restored/export.complete.json', '{"schemaVersion":"dsh-scout.export.v1","complete":true}\n')
|
|
383
|
+
|
|
384
|
+
const mounted = mountPlugin({ config: { scoutDir: '/tmp/scout-cases' }, services: { fs: mockFs(store) } })
|
|
385
|
+
const report = await mounted.tools.get('scout_report').execute({ caseId: 'restored' }, execFor())
|
|
386
|
+
assert.match(report.report, /# Restored Co — HR Head/)
|
|
387
|
+
|
|
388
|
+
const exported = await mounted.tools.get('scout_export').execute({
|
|
389
|
+
caseId: 'restored', targetDir: '/tmp/out',
|
|
390
|
+
}, execFor())
|
|
391
|
+
assert.equal(exported.persisted, true)
|
|
392
|
+
assert.match(store.get('/tmp/out/events.jsonl'), /"type":"case_rehydrated"/)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('an unknown case reports the known cases and how to restore one', async () => {
|
|
396
|
+
const mounted = mountPlugin({ services: { fs: mockFs() } })
|
|
397
|
+
await mounted.tools.get('scout_start').execute({
|
|
398
|
+
caseId: 'known', companyName: 'Known Co', roleTitle: 'HR Head', location: '',
|
|
399
|
+
}, execFor())
|
|
400
|
+
|
|
401
|
+
await assert.rejects(
|
|
402
|
+
() => mounted.tools.get('scout_report').execute({ caseId: 'missing' }, execFor()),
|
|
403
|
+
error => {
|
|
404
|
+
assert.match(error.message, /Unknown case: missing/)
|
|
405
|
+
assert.match(error.message, /Known cases in this session: known/)
|
|
406
|
+
assert.match(error.message, /scout_import/)
|
|
407
|
+
return true
|
|
408
|
+
},
|
|
409
|
+
)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
/* ------------------------------------------------------------------ *
|
|
413
|
+
* Ingestion, search, comparison
|
|
414
|
+
* ------------------------------------------------------------------ */
|
|
415
|
+
|
|
416
|
+
test('scout_questions returns the derived question list', async () => {
|
|
417
|
+
const mounted = mountPlugin()
|
|
418
|
+
await mounted.tools.get('scout_start').execute({
|
|
419
|
+
caseId: 'questions', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
420
|
+
}, execFor())
|
|
421
|
+
const value = await mounted.tools.get('scout_questions').execute({ caseId: 'questions' }, execFor())
|
|
422
|
+
assertSchemaConforms(mounted, 'scout_questions', value)
|
|
423
|
+
assert.ok(value.questions.length > 0)
|
|
424
|
+
assert.equal(new Set(value.questions).size, value.questions.length)
|
|
425
|
+
assert.match(renderedText(renderOutput(mounted, 'scout_questions', value)), /1\. /)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
test('scout_ingest batch-registers sources with inference and isolates errors', async () => {
|
|
429
|
+
const mounted = mountPlugin()
|
|
430
|
+
await mounted.tools.get('scout_start').execute({
|
|
431
|
+
caseId: 'ingest', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
432
|
+
}, execFor())
|
|
433
|
+
|
|
434
|
+
const result = await mounted.tools.get('scout_ingest').execute({
|
|
435
|
+
caseId: 'ingest',
|
|
436
|
+
itemsJson: JSON.stringify([
|
|
437
|
+
{ url: 'https://www.gsxt.gov.cn/company/1', title: 'Registry record' },
|
|
438
|
+
{ url: 'https://www.liepin.com/job/1', title: 'Job posting' },
|
|
439
|
+
{ url: 'https://example.com/unknown', title: 'Unclassified origin' },
|
|
440
|
+
{ url: 'https://example.com/bad-enum', title: 'Bad enum', sourceType: 'not-a-type' },
|
|
441
|
+
{ title: 'Missing url' },
|
|
442
|
+
]),
|
|
443
|
+
}, execFor())
|
|
444
|
+
|
|
445
|
+
assertSchemaConforms(mounted, 'scout_ingest', result)
|
|
446
|
+
assert.equal(result.counts.sources, 3)
|
|
447
|
+
assert.equal(result.counts.claims, 0)
|
|
448
|
+
assert.equal(result.errors.length, 2, 'the invalid sourceType and the missing url are both reported')
|
|
449
|
+
|
|
450
|
+
const report = (await mounted.tools.get('scout_report').execute({ caseId: 'ingest' }, execFor())).report
|
|
451
|
+
assert.match(report, /gsxt\.gov\.cn/)
|
|
452
|
+
assert.match(report, /company_registry \/ E3/)
|
|
453
|
+
assert.match(report, /job_posting \/ E2/)
|
|
454
|
+
assert.match(report, /other \/ E2/)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('scout_ingest reuses an already-registered URL instead of duplicating the source', async () => {
|
|
458
|
+
const mounted = mountPlugin()
|
|
459
|
+
await mounted.tools.get('scout_start').execute({
|
|
460
|
+
caseId: 'dedupe', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
461
|
+
}, execFor())
|
|
462
|
+
const ingest = mounted.tools.get('scout_ingest')
|
|
463
|
+
const payload = JSON.stringify([{ url: 'https://example.com/report', title: 'Report' }])
|
|
464
|
+
|
|
465
|
+
const first = await ingest.execute({ caseId: 'dedupe', itemsJson: payload }, execFor())
|
|
466
|
+
assert.deepEqual(first.changed.ids, ['src-1'])
|
|
467
|
+
const second = await ingest.execute({ caseId: 'dedupe', itemsJson: payload }, execFor())
|
|
468
|
+
assert.deepEqual(second.changed.ids, ['src-1'], 'the same URL must reuse its source id')
|
|
469
|
+
assert.equal(second.counts.sources, 1)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('scout_ingest drafts claims from items and keeps claim ids collision-safe', async () => {
|
|
473
|
+
const mounted = mountPlugin()
|
|
474
|
+
await mounted.tools.get('scout_start').execute({
|
|
475
|
+
caseId: 'draft', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
476
|
+
}, execFor())
|
|
477
|
+
const result = await mounted.tools.get('scout_ingest').execute({
|
|
478
|
+
caseId: 'draft',
|
|
479
|
+
itemsJson: JSON.stringify([
|
|
480
|
+
{ url: 'https://example.com/a', claim: { text: 'The role exists.', dimension: 'role_existence', impact: 'blocking' } },
|
|
481
|
+
{ url: 'https://example.com/b', claim: { text: 'Reporting line is unclear.', dimension: 'reporting_line' } },
|
|
482
|
+
]),
|
|
483
|
+
}, execFor())
|
|
484
|
+
|
|
485
|
+
assert.equal(result.counts.claims, 2)
|
|
486
|
+
assert.equal(new Set(result.changed.ids).size, result.changed.ids.length)
|
|
487
|
+
const report = (await mounted.tools.get('scout_report').execute({ caseId: 'draft' }, execFor())).report
|
|
488
|
+
assert.match(report, /The role exists\./)
|
|
489
|
+
assert.match(report, /Reporting line is unclear\./)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('scout_ingest rejects malformed claims without aborting the batch', async () => {
|
|
493
|
+
const mounted = mountPlugin()
|
|
494
|
+
await mounted.tools.get('scout_start').execute({
|
|
495
|
+
caseId: 'reject', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
496
|
+
}, execFor())
|
|
497
|
+
const result = await mounted.tools.get('scout_ingest').execute({
|
|
498
|
+
caseId: 'reject',
|
|
499
|
+
itemsJson: JSON.stringify([
|
|
500
|
+
{ url: 'https://example.com/a', claim: { text: 'Bad status.', dimension: 'risk', status: 'certain' } },
|
|
501
|
+
{ url: 'https://example.com/b', claim: { text: 'Bad dimension.', dimension: 'nonsense' } },
|
|
502
|
+
{ url: 'https://example.com/c', claim: 'not-an-object' },
|
|
503
|
+
{ url: 'https://example.com/d', claim: { text: 'No source can back E3.', dimension: 'risk', evidenceLevel: 'E3' } },
|
|
504
|
+
{ url: 'https://example.com/e', claim: { text: 'This one is fine.', dimension: 'risk' } },
|
|
505
|
+
]),
|
|
506
|
+
}, execFor())
|
|
507
|
+
|
|
508
|
+
assert.equal(result.counts.claims, 1)
|
|
509
|
+
assert.equal(result.errors.length, 4)
|
|
510
|
+
assert.equal(result.counts.sources, 5, 'a rejected claim must not undo its registered source')
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
test('scout_search registers provider results, dedupes them, and requires the web service', async () => {
|
|
514
|
+
const withoutWeb = mountPlugin()
|
|
515
|
+
await withoutWeb.tools.get('scout_start').execute({
|
|
516
|
+
caseId: 'search', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
517
|
+
}, execFor())
|
|
518
|
+
await assert.rejects(
|
|
519
|
+
() => withoutWeb.tools.get('scout_search').execute({ caseId: 'search', query: 'x' }, execFor()),
|
|
520
|
+
/Web search service is unavailable/,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
let seenLimit
|
|
524
|
+
const mounted = mountPlugin({
|
|
525
|
+
services: {
|
|
526
|
+
web: {
|
|
527
|
+
async search(request) {
|
|
528
|
+
seenLimit = request.maxResults
|
|
529
|
+
return {
|
|
530
|
+
sources: [
|
|
531
|
+
{ url: 'https://www.gsxt.gov.cn/a', title: 'Registry' },
|
|
532
|
+
{ url: 'https://example.com/b', title: 'Media' },
|
|
533
|
+
{ title: 'No url' },
|
|
534
|
+
'not-an-object',
|
|
535
|
+
],
|
|
536
|
+
truncated: true,
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
})
|
|
542
|
+
await mounted.tools.get('scout_start').execute({
|
|
543
|
+
caseId: 'search', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
544
|
+
}, execFor())
|
|
545
|
+
|
|
546
|
+
const searchTool = mounted.tools.get('scout_search')
|
|
547
|
+
const first = await searchTool.execute({ caseId: 'search', query: 'example', limit: 99 }, execFor())
|
|
548
|
+
assertSchemaConforms(mounted, 'scout_search', first)
|
|
549
|
+
assert.equal(seenLimit, 10, 'limit must be capped at 10')
|
|
550
|
+
assert.equal(first.counts.sources, 2)
|
|
551
|
+
assert.equal(first.errors.length, 2, 'a source without a url and a non-object are both rejected')
|
|
552
|
+
|
|
553
|
+
const second = await searchTool.execute({ caseId: 'search', query: 'example', limit: 5 }, execFor())
|
|
554
|
+
assert.equal(second.counts.sources, 2, 're-running the same search must not duplicate sources')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('scout_compare renders a comparison, dedupes ids and rejects a single case', async () => {
|
|
558
|
+
const mounted = mountPlugin()
|
|
559
|
+
const start = mounted.tools.get('scout_start')
|
|
560
|
+
await start.execute({ caseId: 'a', companyName: 'Company A', roleTitle: 'HR Head', location: '' }, execFor())
|
|
561
|
+
await start.execute({ caseId: 'b', companyName: 'Company B', roleTitle: 'HRBP', location: '' }, execFor())
|
|
562
|
+
|
|
563
|
+
const compare = mounted.tools.get('scout_compare')
|
|
564
|
+
const value = await compare.execute({ caseIds: 'a, a, b' }, execFor())
|
|
565
|
+
assertSchemaConforms(mounted, 'scout_compare', value)
|
|
566
|
+
assert.deepEqual(value.caseIds, ['a', 'b'])
|
|
567
|
+
assert.match(value.report, /Company A/)
|
|
568
|
+
assert.match(value.report, /Company B/)
|
|
569
|
+
|
|
570
|
+
await assert.rejects(() => compare.execute({ caseIds: 'a' }, execFor()), /at least two distinct/)
|
|
571
|
+
await assert.rejects(() => compare.execute({ caseIds: ' , ' }, execFor()), /No case ids provided/)
|
|
572
|
+
await assert.rejects(() => compare.execute({ caseIds: 'a, missing' }, execFor()), /Unknown case: missing/)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
/* ------------------------------------------------------------------ *
|
|
576
|
+
* Config and evidence policy through the tool surface
|
|
577
|
+
* ------------------------------------------------------------------ */
|
|
578
|
+
|
|
579
|
+
test('exports a schemastery Config with sensible defaults', () => {
|
|
580
|
+
const resolved = new Config({})
|
|
581
|
+
assert.equal(resolved.scoutDir, 'dsh-scout')
|
|
582
|
+
assert.equal(resolved.autoPersist, false)
|
|
583
|
+
assert.ok(Array.isArray(resolved.authorityHostSuffixes))
|
|
584
|
+
assert.ok(resolved.authorityHostSuffixes.includes('gov.cn'))
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
test('custom authority host suffixes replace the E3 trust boundary', async () => {
|
|
588
|
+
const mounted = mountPlugin({ config: { authorityHostSuffixes: ['example.gov'] } })
|
|
589
|
+
await mounted.tools.get('scout_start').execute({
|
|
590
|
+
caseId: 'authority', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
591
|
+
}, execFor())
|
|
592
|
+
|
|
593
|
+
// The replacement list trusts example.gov ...
|
|
594
|
+
const accepted = await mounted.tools.get('scout_add_source').execute({
|
|
595
|
+
caseId: 'authority', sourceId: 'registry', sourceType: 'company_registry',
|
|
596
|
+
title: 'Registry', url: 'https://registry.example.gov/co/1', evidenceLevel: 'E3',
|
|
597
|
+
}, execFor())
|
|
598
|
+
assert.equal(accepted.counts.sources, 1)
|
|
599
|
+
|
|
600
|
+
// ... and no longer trusts the built-in gsxt origin.
|
|
601
|
+
await assert.rejects(
|
|
602
|
+
() => mounted.tools.get('scout_add_source').execute({
|
|
603
|
+
caseId: 'authority', sourceId: 'gsxt', sourceType: 'company_registry',
|
|
604
|
+
title: 'Gsxt', url: 'https://www.gsxt.gov.cn/co/9', evidenceLevel: 'E3',
|
|
605
|
+
}, execFor()),
|
|
606
|
+
/E3 sources require a supported registry/,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
const verified = await mounted.tools.get('scout_verify_identity').execute({
|
|
610
|
+
caseId: 'authority', legalEntity: 'Example Co Ltd', registrationNumber: '123',
|
|
611
|
+
registeredRegion: 'Shenzhen', legalRepresentative: 'Zhang San',
|
|
612
|
+
brandRelationship: 'Operating entity', sourceIds: 'registry',
|
|
613
|
+
}, execFor())
|
|
614
|
+
assert.equal(verified.identity, 'verified')
|
|
615
|
+
|
|
616
|
+
assert.equal(isTrustedAuthorityUrl('https://www.gsxt.gov.cn/a', ['example.gov']), false)
|
|
617
|
+
assert.equal(isTrustedAuthorityUrl('https://registry.example.gov/a', ['example.gov']), true)
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
test('the evidence constraints stay enforced through the tool surface', async () => {
|
|
621
|
+
const mounted = mountPlugin()
|
|
622
|
+
await mounted.tools.get('scout_start').execute({
|
|
623
|
+
caseId: 'evidence', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
624
|
+
}, execFor())
|
|
625
|
+
const addSource = mounted.tools.get('scout_add_source')
|
|
626
|
+
const addClaim = mounted.tools.get('scout_add_claim')
|
|
627
|
+
|
|
628
|
+
// A self-labelled E3 source outside a trusted authority origin is refused.
|
|
629
|
+
await assert.rejects(
|
|
630
|
+
() => addSource.execute({
|
|
631
|
+
caseId: 'evidence', sourceId: 'fake', sourceType: 'company_registry',
|
|
632
|
+
title: 'Fake registry', url: 'https://not-really-a-registry.example/', evidenceLevel: 'E3',
|
|
633
|
+
}, execFor()),
|
|
634
|
+
/E3 sources require a supported registry/,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
await addSource.execute({
|
|
638
|
+
caseId: 'evidence', sourceId: 'media', sourceType: 'independent_media',
|
|
639
|
+
title: 'Media', url: 'https://example.com/media', evidenceLevel: 'E2',
|
|
640
|
+
}, execFor())
|
|
641
|
+
|
|
642
|
+
// A claim may not exceed its strongest source.
|
|
643
|
+
await assert.rejects(
|
|
644
|
+
() => addClaim.execute({
|
|
645
|
+
caseId: 'evidence', claimId: 'c1', text: 'Over-claimed.', status: 'verified',
|
|
646
|
+
evidenceLevel: 'E3', dimension: 'risk', impact: 'material', sourceIds: 'media',
|
|
647
|
+
confidenceNote: 'No.', nextAction: 'Verify.',
|
|
648
|
+
}, execFor()),
|
|
649
|
+
/exceeds its strongest source/,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
// A claim cannot be verified on E0.
|
|
653
|
+
await assert.rejects(
|
|
654
|
+
() => addClaim.execute({
|
|
655
|
+
caseId: 'evidence', claimId: 'c2', text: 'No evidence.', status: 'verified',
|
|
656
|
+
evidenceLevel: 'E0', dimension: 'risk', impact: 'material', sourceIds: 'media',
|
|
657
|
+
confidenceNote: 'No.', nextAction: 'Verify.',
|
|
658
|
+
}, execFor()),
|
|
659
|
+
/at least E1/,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
// A claim with no source must stay unknown / needs_verification.
|
|
663
|
+
await assert.rejects(
|
|
664
|
+
() => addClaim.execute({
|
|
665
|
+
caseId: 'evidence', claimId: 'c3', text: 'Unsourced.', status: 'reported',
|
|
666
|
+
evidenceLevel: 'E1', dimension: 'risk', impact: 'material', sourceIds: '',
|
|
667
|
+
confidenceNote: 'No.', nextAction: 'Verify.',
|
|
668
|
+
}, execFor()),
|
|
669
|
+
/must remain unknown or needs_verification/,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
const ok = await addClaim.execute({
|
|
673
|
+
caseId: 'evidence', claimId: 'c4', text: 'Sourced finding.', status: 'reported',
|
|
674
|
+
evidenceLevel: 'E2', dimension: 'risk', impact: 'material', sourceIds: 'media',
|
|
675
|
+
confidenceNote: 'Independent media.', nextAction: 'Cross-check.',
|
|
676
|
+
}, execFor())
|
|
677
|
+
assertSchemaConforms(mounted, 'scout_add_claim', ok)
|
|
678
|
+
assert.equal(ok.counts.claims, 1)
|
|
679
|
+
assert.deepEqual(ok.nextActions, ['Cross-check.'])
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
test('inference helpers keep matching the documented classification', () => {
|
|
683
|
+
assert.deepEqual(inferSourceType('https://www.gsxt.gov.cn/a'), { type: 'company_registry', inferred: true })
|
|
684
|
+
assert.deepEqual(inferSourceType('https://www.liepin.com/a'), { type: 'job_posting', inferred: true })
|
|
685
|
+
assert.equal(inferEvidenceLevel('https://www.gsxt.gov.cn/a', 'company_registry').evidenceLevel, 'E3')
|
|
686
|
+
assert.equal(inferEvidenceLevel('https://example.com/a', 'other').evidenceLevel, 'E2')
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
/* ------------------------------------------------------------------ *
|
|
690
|
+
* 0.9.1: coverage, language, normalized dedupe
|
|
691
|
+
* ------------------------------------------------------------------ */
|
|
692
|
+
|
|
693
|
+
test('scout_report returns per-dimension coverage alongside the rendered report', async () => {
|
|
694
|
+
const mounted = mountPlugin()
|
|
695
|
+
await mounted.tools.get('scout_start').execute({
|
|
696
|
+
caseId: 'coverage', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
697
|
+
}, execFor())
|
|
698
|
+
|
|
699
|
+
const value = await mounted.tools.get('scout_report').execute({ caseId: 'coverage' }, execFor())
|
|
700
|
+
assertSchemaConforms(mounted, 'scout_report', value)
|
|
701
|
+
assert.equal(value.coverage.length, 11)
|
|
702
|
+
assert.ok(value.coverage.every(entry => entry.untouched === true))
|
|
703
|
+
const identity = value.coverage.find(entry => entry.dimension === 'identity')
|
|
704
|
+
assert.deepEqual(identity, { dimension: 'identity', verified: 0, open: 0, untouched: true })
|
|
705
|
+
assert.match(value.report, /## Coverage/)
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
test('the configured language drives the report, the questions and the structured value', async () => {
|
|
709
|
+
const mounted = mountPlugin({ config: { language: 'en' } })
|
|
710
|
+
await mounted.tools.get('scout_start').execute({
|
|
711
|
+
caseId: 'english', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
712
|
+
}, execFor())
|
|
713
|
+
|
|
714
|
+
const report = (await mounted.tools.get('scout_report').execute({ caseId: 'english' }, execFor())).report
|
|
715
|
+
assert.match(report, /- Decision: \*\*VERIFY\*\*/)
|
|
716
|
+
assert.match(report, /- Reason: /)
|
|
717
|
+
assert.doesNotMatch(report, /判断|理由/)
|
|
718
|
+
|
|
719
|
+
const questions = await mounted.tools.get('scout_questions').execute({ caseId: 'english' }, execFor())
|
|
720
|
+
assert.ok(questions.questions.length > 0)
|
|
721
|
+
assert.ok(questions.questions.some(question => /role|reporting|mandate|decision/i.test(question)))
|
|
722
|
+
|
|
723
|
+
// The default deployment stays byte-compatible in Chinese.
|
|
724
|
+
const chinese = mountPlugin()
|
|
725
|
+
await chinese.tools.get('scout_start').execute({
|
|
726
|
+
caseId: 'chinese', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
727
|
+
}, execFor())
|
|
728
|
+
const zhReport = (await chinese.tools.get('scout_report').execute({ caseId: 'chinese' }, execFor())).report
|
|
729
|
+
assert.match(zhReport, /- 判断:\*\*VERIFY\*\*/)
|
|
730
|
+
|
|
731
|
+
const defaults = new Config({})
|
|
732
|
+
assert.equal(defaults.language, 'zh-CN')
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
test('URL dedupe normalizes equivalents before creating a second source', async () => {
|
|
736
|
+
const mounted = mountPlugin()
|
|
737
|
+
await mounted.tools.get('scout_start').execute({
|
|
738
|
+
caseId: 'normalized', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
739
|
+
}, execFor())
|
|
740
|
+
const ingest = mounted.tools.get('scout_ingest')
|
|
741
|
+
|
|
742
|
+
const first = await ingest.execute({
|
|
743
|
+
caseId: 'normalized',
|
|
744
|
+
itemsJson: JSON.stringify([{ url: 'https://Example.com/report/', title: 'Report' }]),
|
|
745
|
+
}, execFor())
|
|
746
|
+
const second = await ingest.execute({
|
|
747
|
+
caseId: 'normalized',
|
|
748
|
+
itemsJson: JSON.stringify([{ url: 'https://example.com/report', title: 'Report' }]),
|
|
749
|
+
}, execFor())
|
|
750
|
+
|
|
751
|
+
assert.deepEqual(second.changed.ids, first.changed.ids, 'host case and a trailing slash must not fork the source')
|
|
752
|
+
assert.equal(second.counts.sources, 1)
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
/* ------------------------------------------------------------------ *
|
|
756
|
+
* 0.9.1 verification fixes: marker integrity, rehydration strictness,
|
|
757
|
+
* and the enforced render budget.
|
|
758
|
+
* ------------------------------------------------------------------ */
|
|
759
|
+
|
|
760
|
+
test('the completion marker carries complete:false while an export is in flight', async () => {
|
|
761
|
+
let failAfterMarker = false
|
|
762
|
+
const store = new Map()
|
|
763
|
+
const fs = {
|
|
764
|
+
store,
|
|
765
|
+
async resolve(path) {
|
|
766
|
+
return { targetKey: path, displayPath: path }
|
|
767
|
+
},
|
|
768
|
+
async readText(target) {
|
|
769
|
+
if (!store.has(target.displayPath)) throw new Error(`not found: ${target.displayPath}`)
|
|
770
|
+
return store.get(target.displayPath)
|
|
771
|
+
},
|
|
772
|
+
async writeText(target, content) {
|
|
773
|
+
if (failAfterMarker && target.displayPath.endsWith('claims.json')) throw new Error('disk full')
|
|
774
|
+
store.set(target.displayPath, content)
|
|
775
|
+
return { operation: 'create' }
|
|
776
|
+
},
|
|
777
|
+
}
|
|
778
|
+
const mounted = mountPlugin({ services: { fs } })
|
|
779
|
+
await mounted.tools.get('scout_start').execute({
|
|
780
|
+
caseId: 'torn', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
781
|
+
}, execFor())
|
|
782
|
+
|
|
783
|
+
// A first, complete export.
|
|
784
|
+
const good = await mounted.tools.get('scout_export').execute({ caseId: 'torn', targetDir: '/tmp/torn' }, execFor())
|
|
785
|
+
assert.equal(good.complete, true)
|
|
786
|
+
assert.equal(JSON.parse(store.get('/tmp/torn/export.complete.json')).complete, true)
|
|
787
|
+
|
|
788
|
+
// A later export that dies in the middle must not leave a stale "complete".
|
|
789
|
+
failAfterMarker = true
|
|
790
|
+
const bad = await mounted.tools.get('scout_export').execute({ caseId: 'torn', targetDir: '/tmp/torn' }, execFor())
|
|
791
|
+
assert.equal(bad.persisted, false)
|
|
792
|
+
assert.equal(bad.complete, false)
|
|
793
|
+
assert.equal(
|
|
794
|
+
JSON.parse(store.get('/tmp/torn/export.complete.json')).complete,
|
|
795
|
+
false,
|
|
796
|
+
'the marker must be invalidated before the payload is rewritten',
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
failAfterMarker = false
|
|
800
|
+
const imported = await mounted.tools.get('scout_import').execute({ caseId: 'torn', sourceDir: '/tmp/torn' }, execFor())
|
|
801
|
+
assert.equal(imported.complete, false, 'a mixed-generation directory must not report complete')
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
test('the completion marker records the export it belongs to', async () => {
|
|
805
|
+
const fs = mockFs()
|
|
806
|
+
const mounted = mountPlugin({ services: { fs } })
|
|
807
|
+
await mounted.tools.get('scout_start').execute({
|
|
808
|
+
caseId: 'marker', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
809
|
+
}, execFor())
|
|
810
|
+
await mounted.tools.get('scout_export').execute({ caseId: 'marker', targetDir: '/tmp/marker' }, execFor())
|
|
811
|
+
|
|
812
|
+
const marker = JSON.parse(fs.store.get('/tmp/marker/export.complete.json'))
|
|
813
|
+
assert.equal(marker.caseId, 'marker')
|
|
814
|
+
assert.equal(marker.complete, true)
|
|
815
|
+
assert.deepEqual(marker.files, ['case.json', 'sources.json', 'claims.json', 'events.jsonl', 'report.md'])
|
|
816
|
+
const eventLines = fs.store.get('/tmp/marker/events.jsonl').trim().split('\n')
|
|
817
|
+
assert.equal(marker.eventCount, eventLines.length, 'marker must describe the events it shipped with')
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
test('lazy rehydration refuses an export that never completed', async () => {
|
|
821
|
+
const store = new Map()
|
|
822
|
+
const files = exportCaseFiles(createCase({ caseId: 'partial', companyName: 'Partial Co', roleTitle: 'HR Head' }))
|
|
823
|
+
for (const [fileName, content] of Object.entries(files)) {
|
|
824
|
+
store.set(`/tmp/scout-cases/partial/${fileName}`, content)
|
|
825
|
+
}
|
|
826
|
+
// The marker exists but was never re-asserted as complete.
|
|
827
|
+
store.set('/tmp/scout-cases/partial/export.complete.json', '{"schemaVersion":"dsh-scout.export.v1","complete":false}\n')
|
|
828
|
+
|
|
829
|
+
const mounted = mountPlugin({ config: { scoutDir: '/tmp/scout-cases' }, services: { fs: mockFs(store) } })
|
|
830
|
+
await assert.rejects(
|
|
831
|
+
() => mounted.tools.get('scout_report').execute({ caseId: 'partial' }, execFor()),
|
|
832
|
+
/Unknown case: partial/,
|
|
833
|
+
'an incomplete export must not silently become this session case state',
|
|
834
|
+
)
|
|
835
|
+
// The explicit import path still works, and says so honestly.
|
|
836
|
+
store.set('/tmp/partial/export.complete.json', '{"schemaVersion":"dsh-scout.export.v1","complete":false}\n')
|
|
837
|
+
for (const [fileName, content] of Object.entries(files)) {
|
|
838
|
+
store.set(`/tmp/partial/${fileName}`, content)
|
|
839
|
+
}
|
|
840
|
+
const imported = await mounted.tools.get('scout_import').execute({ caseId: 'partial', sourceDir: '/tmp/partial' }, execFor())
|
|
841
|
+
assert.equal(imported.complete, false)
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
test('the render budget holds even for pathologically long free-text fields', async () => {
|
|
845
|
+
const mounted = mountPlugin()
|
|
846
|
+
await mounted.tools.get('scout_start').execute({
|
|
847
|
+
caseId: 'long', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
848
|
+
}, execFor())
|
|
849
|
+
await mounted.tools.get('scout_add_source').execute({
|
|
850
|
+
caseId: 'long', sourceId: 'media', sourceType: 'independent_media',
|
|
851
|
+
title: 'Media', url: 'https://example.com/media', evidenceLevel: 'E2',
|
|
852
|
+
}, execFor())
|
|
853
|
+
|
|
854
|
+
const value = await mounted.tools.get('scout_add_claim').execute({
|
|
855
|
+
caseId: 'long', claimId: 'c1', text: 'A very long next action.', status: 'reported',
|
|
856
|
+
evidenceLevel: 'E2', dimension: 'risk', impact: 'material', sourceIds: 'media',
|
|
857
|
+
confidenceNote: 'x'.repeat(400),
|
|
858
|
+
nextAction: 'y'.repeat(3000),
|
|
859
|
+
}, execFor())
|
|
860
|
+
|
|
861
|
+
assert.equal(value.nextActions[0].length, 3000, 'the canonical value keeps the full text')
|
|
862
|
+
const content = renderOutput(mounted, 'scout_add_claim', value)
|
|
863
|
+
assert.ok(outputBytes(content) <= 1024, `render was ${outputBytes(content)} bytes`)
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
test('a language change re-localizes untouched case-authored copy but keeps user edits', async () => {
|
|
867
|
+
const store = new Map()
|
|
868
|
+
const seed = (caseId, dir, mutate) => {
|
|
869
|
+
let scoutCase = createCase({ caseId, companyName: 'Example Co', roleTitle: 'HR Head' })
|
|
870
|
+
if (mutate) scoutCase = mutate(scoutCase)
|
|
871
|
+
for (const [fileName, content] of Object.entries(exportCaseFiles(scoutCase))) {
|
|
872
|
+
store.set(`${dir}/${fileName}`, content)
|
|
873
|
+
}
|
|
874
|
+
store.set(`${dir}/export.complete.json`, '{"schemaVersion":"dsh-scout.export.v1","complete":true}\n')
|
|
875
|
+
}
|
|
876
|
+
seed('relabel', '/tmp/relabel')
|
|
877
|
+
seed('edited', '/tmp/edited', scoutCase => ({
|
|
878
|
+
...scoutCase,
|
|
879
|
+
decisionObjective: 'Prüfen, ob diese Rolle passt.',
|
|
880
|
+
}))
|
|
881
|
+
|
|
882
|
+
const mounted = mountPlugin({ config: { language: 'en' }, services: { fs: mockFs(store) } })
|
|
883
|
+
const cjk = /[\u4e00-\u9fff]/
|
|
884
|
+
|
|
885
|
+
const imported = await mounted.tools.get('scout_import').execute({ caseId: 'relabel', sourceDir: '/tmp/relabel' }, execFor())
|
|
886
|
+
assert.equal(imported.complete, true)
|
|
887
|
+
const report = (await mounted.tools.get('scout_report').execute({ caseId: 'relabel' }, execFor())).report
|
|
888
|
+
assert.doesNotMatch(report, cjk, 'an en deployment must not render mixed-language case prose')
|
|
889
|
+
assert.match(report, /- Objective: Decide whether this role is worth pursuing/)
|
|
890
|
+
const questions = await mounted.tools.get('scout_questions').execute({ caseId: 'relabel' }, execFor())
|
|
891
|
+
assert.ok(questions.questions.every(question => !cjk.test(question)), 'interview questions must follow the deployment language')
|
|
892
|
+
|
|
893
|
+
// The exported case.json now agrees with its own content language.
|
|
894
|
+
const exported = await mounted.tools.get('scout_export').execute({ caseId: 'relabel', targetDir: '/tmp/relabel-out' }, execFor())
|
|
895
|
+
assert.equal(exported.persisted, true)
|
|
896
|
+
const caseJson = JSON.parse(store.get('/tmp/relabel-out/case.json'))
|
|
897
|
+
assert.equal(caseJson.language, 'en')
|
|
898
|
+
assert.doesNotMatch(caseJson.decisionObjective, cjk)
|
|
899
|
+
|
|
900
|
+
// A value the user wrote is not a factory default and must survive untouched.
|
|
901
|
+
await mounted.tools.get('scout_import').execute({ caseId: 'edited', sourceDir: '/tmp/edited' }, execFor())
|
|
902
|
+
const edited = (await mounted.tools.get('scout_report').execute({ caseId: 'edited' }, execFor())).report
|
|
903
|
+
assert.match(edited, /Prüfen, ob diese Rolle passt\./)
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
/* ------------------------------------------------------------------ *
|
|
907
|
+
* Optional seams: the /scout command and the scout Skill
|
|
908
|
+
* ------------------------------------------------------------------ */
|
|
909
|
+
|
|
910
|
+
test('the /scout command and scout Skill register definitions the host accepts', async () => {
|
|
911
|
+
const commands = []
|
|
912
|
+
const skills = []
|
|
913
|
+
mountPlugin({
|
|
914
|
+
services: {
|
|
915
|
+
commands: {
|
|
916
|
+
register(definition) {
|
|
917
|
+
commands.push(definition)
|
|
918
|
+
return () => undefined
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
skills: {
|
|
922
|
+
register(definition) {
|
|
923
|
+
skills.push(definition)
|
|
924
|
+
return () => undefined
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
// CommandDefinition: lowercase slug, non-empty description, function handler.
|
|
931
|
+
assert.equal(commands.length, 1)
|
|
932
|
+
const command = commands[0]
|
|
933
|
+
assert.match(command.name, /^[a-z][a-z0-9_-]*$/, 'COMMAND_NAME requires a lowercase slug')
|
|
934
|
+
assert.ok(command.description.trim().length > 0)
|
|
935
|
+
assert.equal(typeof command.handler, 'function')
|
|
936
|
+
|
|
937
|
+
// CommandResult: success carries optional text, error must carry text.
|
|
938
|
+
const help = await command.handler({ rawInput: '', agent: { id: 'session-a' } })
|
|
939
|
+
assert.equal(help.kind, 'success')
|
|
940
|
+
assert.match(help.text, /\/scout status/)
|
|
941
|
+
const missing = await command.handler({ rawInput: 'status nope', agent: { id: 'session-a' } })
|
|
942
|
+
assert.equal(missing.kind, 'error')
|
|
943
|
+
assert.equal(typeof missing.text, 'string')
|
|
944
|
+
assert.ok(missing.text.length > 0)
|
|
945
|
+
|
|
946
|
+
// SkillRegistration: name is kebab-case, description/content non-empty, and
|
|
947
|
+
// `source` stays required — the registry defaults `provider` but never `source`,
|
|
948
|
+
// so omitting it yields a summary whose declared non-optional source is undefined.
|
|
949
|
+
assert.equal(skills.length, 1)
|
|
950
|
+
const skill = skills[0]
|
|
951
|
+
assert.match(skill.name, /^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'SKILL_NAME requires a kebab-case identifier')
|
|
952
|
+
assert.ok(skill.description.trim().length > 0)
|
|
953
|
+
assert.ok(skill.content.length > 0)
|
|
954
|
+
assert.equal(typeof skill.source, 'string')
|
|
955
|
+
assert.ok(skill.source.length > 0, 'source is required by SkillRegistration and is never defaulted')
|
|
956
|
+
assert.deepEqual(skill.invocation, { modelInvocable: true, userInvocable: true })
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
test('an absent optional service only skips that registration', async () => {
|
|
960
|
+
const skills = []
|
|
961
|
+
mountPlugin({
|
|
962
|
+
services: {
|
|
963
|
+
skills: {
|
|
964
|
+
register(definition) {
|
|
965
|
+
skills.push(definition)
|
|
966
|
+
return () => undefined
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
})
|
|
971
|
+
// `commands` was never mounted, so nothing is registered for it and the plugin
|
|
972
|
+
// still loads (the real binding leaves that child fiber pending).
|
|
973
|
+
assert.equal(skills.length, 1)
|
|
974
|
+
})
|