dsh-scout 0.9.2 → 0.9.4
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 +76 -1
- package/README.en.md +140 -47
- package/README.md +140 -47
- package/dist/i18n.d.ts +157 -1
- package/dist/i18n.js +354 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +360 -88
- package/dist/model.d.ts +62 -9
- package/dist/model.js +655 -134
- package/docs/dsh-scout-product-contract.md +22 -1
- package/package.json +4 -1
- package/scripts/acceptance-report.mjs +622 -0
- package/scripts/check-release.mjs +228 -0
- package/scripts/profile-smoke.mjs +12 -15
- package/scripts/smoke-tool-call.mjs +43 -0
- package/tests/acceptance.test.mjs +118 -42
- package/tests/model.test.mjs +301 -69
- package/tests/package.test.mjs +572 -3
- package/tests/tools.test.mjs +382 -16
package/tests/tools.test.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { apply, Config, inject, name } from '../dist/index.js'
|
|
|
5
5
|
import {
|
|
6
6
|
createCase,
|
|
7
7
|
exportCaseFiles,
|
|
8
|
+
importCaseFromFiles,
|
|
8
9
|
inferEvidenceLevel,
|
|
9
10
|
inferSourceType,
|
|
10
11
|
isTrustedAuthorityUrl,
|
|
@@ -141,9 +142,10 @@ test('isolates identical case ids by DSH agent session identity', async () => {
|
|
|
141
142
|
|
|
142
143
|
const reportA = (await report.execute({ caseId: 'same-id' }, execFor('session-a'))).report
|
|
143
144
|
const reportB = (await report.execute({ caseId: 'same-id' }, execFor('session-b'))).report
|
|
144
|
-
assert.match(reportA,
|
|
145
|
+
assert.match(reportA, /^# Company A$/m)
|
|
146
|
+
assert.match(reportA, /^> 岗位:Role A/m)
|
|
145
147
|
assert.doesNotMatch(reportA, /Company B/)
|
|
146
|
-
assert.match(reportB,
|
|
148
|
+
assert.match(reportB, /^# Company B$/m)
|
|
147
149
|
})
|
|
148
150
|
|
|
149
151
|
/* ------------------------------------------------------------------ *
|
|
@@ -210,8 +212,8 @@ test('scout_report stays the only tool that returns the full artifact', async ()
|
|
|
210
212
|
assertSchemaConforms(mounted, 'scout_report', value)
|
|
211
213
|
assert.equal(value.caseId, 'full')
|
|
212
214
|
assert.equal(value.decision, 'VERIFY')
|
|
213
|
-
assert.match(value.report,
|
|
214
|
-
assert.match(value.report, /##
|
|
215
|
+
assert.match(value.report, /^# Example Co$/m)
|
|
216
|
+
assert.match(value.report, /## 6\. 距离下一步还差什么/)
|
|
215
217
|
})
|
|
216
218
|
|
|
217
219
|
/* ------------------------------------------------------------------ *
|
|
@@ -327,7 +329,7 @@ test('auto-persist failure is surfaced instead of silently swallowed', async ()
|
|
|
327
329
|
|
|
328
330
|
// A failed auto-persist must not claim that an export happened.
|
|
329
331
|
const report = await mounted.tools.get('scout_report').execute({ caseId: 'failing' }, execFor())
|
|
330
|
-
assert.match(report.report,
|
|
332
|
+
assert.match(report.report, /^# Example Co$/m)
|
|
331
333
|
const exported = await mounted.tools.get('scout_export').execute({
|
|
332
334
|
caseId: 'failing', targetDir: '/tmp/nope',
|
|
333
335
|
}, execFor())
|
|
@@ -344,7 +346,7 @@ test('auto-persist without a filesystem service degrades gracefully', async () =
|
|
|
344
346
|
assert.equal(started.persist.persisted, false)
|
|
345
347
|
assert.match(started.persist.error, /Filesystem service is unavailable/)
|
|
346
348
|
const report = await mounted.tools.get('scout_report').execute({ caseId: 'no-fs' }, execFor())
|
|
347
|
-
assert.match(report.report,
|
|
349
|
+
assert.match(report.report, /^# Example Co$/m)
|
|
348
350
|
})
|
|
349
351
|
|
|
350
352
|
/* ------------------------------------------------------------------ *
|
|
@@ -383,7 +385,7 @@ test('a cache miss rehydrates the case from the export directory and records it'
|
|
|
383
385
|
|
|
384
386
|
const mounted = mountPlugin({ config: { scoutDir: '/tmp/scout-cases' }, services: { fs: mockFs(store) } })
|
|
385
387
|
const report = await mounted.tools.get('scout_report').execute({ caseId: 'restored' }, execFor())
|
|
386
|
-
assert.match(report.report,
|
|
388
|
+
assert.match(report.report, /^# Restored Co$/m)
|
|
387
389
|
|
|
388
390
|
const exported = await mounted.tools.get('scout_export').execute({
|
|
389
391
|
caseId: 'restored', targetDir: '/tmp/out',
|
|
@@ -449,9 +451,9 @@ test('scout_ingest batch-registers sources with inference and isolates errors',
|
|
|
449
451
|
|
|
450
452
|
const report = (await mounted.tools.get('scout_report').execute({ caseId: 'ingest' }, execFor())).report
|
|
451
453
|
assert.match(report, /gsxt\.gov\.cn/)
|
|
452
|
-
assert.match(report,
|
|
453
|
-
assert.match(report,
|
|
454
|
-
assert.match(report,
|
|
454
|
+
assert.match(report, /工商登记 \| 官方\/权威来源/)
|
|
455
|
+
assert.match(report, /招聘平台 \| 独立来源/)
|
|
456
|
+
assert.match(report, /其他 \| 独立来源/)
|
|
455
457
|
})
|
|
456
458
|
|
|
457
459
|
test('scout_ingest reuses an already-registered URL instead of duplicating the source', async () => {
|
|
@@ -702,7 +704,7 @@ test('scout_report returns per-dimension coverage alongside the rendered report'
|
|
|
702
704
|
assert.ok(value.coverage.every(entry => entry.untouched === true))
|
|
703
705
|
const identity = value.coverage.find(entry => entry.dimension === 'identity')
|
|
704
706
|
assert.deepEqual(identity, { dimension: 'identity', verified: 0, open: 0, untouched: true })
|
|
705
|
-
assert.match(value.report, /##
|
|
707
|
+
assert.match(value.report, /## 附录 C · 调查覆盖度/)
|
|
706
708
|
})
|
|
707
709
|
|
|
708
710
|
test('the configured language drives the report, the questions and the structured value', async () => {
|
|
@@ -712,8 +714,8 @@ test('the configured language drives the report, the questions and the structure
|
|
|
712
714
|
}, execFor())
|
|
713
715
|
|
|
714
716
|
const report = (await mounted.tools.get('scout_report').execute({ caseId: 'english' }, execFor())).report
|
|
715
|
-
assert.match(report,
|
|
716
|
-
assert.match(report,
|
|
717
|
+
assert.match(report, /^> Role: [^|]+ \| Location: [^|]+ \| Decision: verify \(VERIFY\)$/m)
|
|
718
|
+
assert.match(report, /## 30-second verdict/)
|
|
717
719
|
assert.doesNotMatch(report, /判断|理由/)
|
|
718
720
|
|
|
719
721
|
const questions = await mounted.tools.get('scout_questions').execute({ caseId: 'english' }, execFor())
|
|
@@ -726,7 +728,7 @@ test('the configured language drives the report, the questions and the structure
|
|
|
726
728
|
caseId: 'chinese', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
727
729
|
}, execFor())
|
|
728
730
|
const zhReport = (await chinese.tools.get('scout_report').execute({ caseId: 'chinese' }, execFor())).report
|
|
729
|
-
assert.match(zhReport,
|
|
731
|
+
assert.match(zhReport, /^> 岗位:/m)
|
|
730
732
|
|
|
731
733
|
const defaults = new Config({})
|
|
732
734
|
assert.equal(defaults.language, 'zh-CN')
|
|
@@ -886,7 +888,7 @@ test('a language change re-localizes untouched case-authored copy but keeps user
|
|
|
886
888
|
assert.equal(imported.complete, true)
|
|
887
889
|
const report = (await mounted.tools.get('scout_report').execute({ caseId: 'relabel' }, execFor())).report
|
|
888
890
|
assert.doesNotMatch(report, cjk, 'an en deployment must not render mixed-language case prose')
|
|
889
|
-
assert.match(report,
|
|
891
|
+
assert.match(report, /^> Role: /m)
|
|
890
892
|
const questions = await mounted.tools.get('scout_questions').execute({ caseId: 'relabel' }, execFor())
|
|
891
893
|
assert.ok(questions.questions.every(question => !cjk.test(question)), 'interview questions must follow the deployment language')
|
|
892
894
|
|
|
@@ -900,7 +902,7 @@ test('a language change re-localizes untouched case-authored copy but keeps user
|
|
|
900
902
|
// A value the user wrote is not a factory default and must survive untouched.
|
|
901
903
|
await mounted.tools.get('scout_import').execute({ caseId: 'edited', sourceDir: '/tmp/edited' }, execFor())
|
|
902
904
|
const edited = (await mounted.tools.get('scout_report').execute({ caseId: 'edited' }, execFor())).report
|
|
903
|
-
assert.match(edited,
|
|
905
|
+
assert.match(edited, /> (目标:|Objective: )Prüfen, ob diese Rolle passt\./)
|
|
904
906
|
})
|
|
905
907
|
|
|
906
908
|
/* ------------------------------------------------------------------ *
|
|
@@ -972,3 +974,367 @@ test('an absent optional service only skips that registration', async () => {
|
|
|
972
974
|
// still loads (the real binding leaves that child fiber pending).
|
|
973
975
|
assert.equal(skills.length, 1)
|
|
974
976
|
})
|
|
977
|
+
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
// 0.9.4 regression coverage: host sandbox seam, source upgrades, ingest dedupe,
|
|
980
|
+
// identity tiers, start guard, and input validation.
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
const startArgs = (caseId, overrides = {}) => ({
|
|
984
|
+
caseId, companyName: 'Example Co', roleTitle: 'HR Head', location: '', ...overrides,
|
|
985
|
+
})
|
|
986
|
+
const sessionExec = (cwd = '/workspace/desk') => ({
|
|
987
|
+
agent: { id: 'session-a', session: { header: { cwd } } },
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
test('export forwards the session sandbox policy to a confined filesystem backend', async () => {
|
|
991
|
+
const workspace = '/workspace/desk'
|
|
992
|
+
const writes = []
|
|
993
|
+
const confinedFs = {
|
|
994
|
+
async resolve(path) {
|
|
995
|
+
return { targetKey: path, displayPath: path }
|
|
996
|
+
},
|
|
997
|
+
async readText() {
|
|
998
|
+
throw new Error('not found')
|
|
999
|
+
},
|
|
1000
|
+
async writeText(target, content, intent, signal, sandboxPolicy) {
|
|
1001
|
+
// Mirrors @deepseek-ai/dsh-fs-sandbox: a missing fifth argument means
|
|
1002
|
+
// "deployment-default root", which denies the session workspace.
|
|
1003
|
+
if (!sandboxPolicy) {
|
|
1004
|
+
throw new Error(`cannot write "${target.displayPath}": file access denied under workspace-write mode`)
|
|
1005
|
+
}
|
|
1006
|
+
if (!target.displayPath.startsWith(sandboxPolicy.workspaceRoot)) {
|
|
1007
|
+
throw new Error(`cannot write "${target.displayPath}": file access denied under workspace-write mode`)
|
|
1008
|
+
}
|
|
1009
|
+
writes.push({ path: target.displayPath, policy: sandboxPolicy })
|
|
1010
|
+
},
|
|
1011
|
+
}
|
|
1012
|
+
const sandboxPolicy = {
|
|
1013
|
+
resolve(request) {
|
|
1014
|
+
return {
|
|
1015
|
+
mode: 'workspace-write',
|
|
1016
|
+
workspaceRoot: request?.session?.header?.cwd ?? '/deployment-default',
|
|
1017
|
+
...(request?.session ? { sessionId: 'session-a' } : {}),
|
|
1018
|
+
}
|
|
1019
|
+
},
|
|
1020
|
+
}
|
|
1021
|
+
const mounted = mountPlugin({ services: { fs: confinedFs, sandboxPolicy } })
|
|
1022
|
+
const exec = sessionExec(workspace)
|
|
1023
|
+
await mounted.tools.get('scout_start').execute(startArgs('sandbox'), exec)
|
|
1024
|
+
const exported = await mounted.tools.get('scout_export').execute({
|
|
1025
|
+
caseId: 'sandbox',
|
|
1026
|
+
targetDir: `${workspace}/cases/sandbox`,
|
|
1027
|
+
}, exec)
|
|
1028
|
+
assert.equal(exported.persisted, true)
|
|
1029
|
+
assert.equal(writes.length, 7, 'marker false + five payload files + marker true')
|
|
1030
|
+
assert.ok(writes.every(item => item.policy.workspaceRoot === workspace), 'the session workspace root is forwarded')
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
test('a confinement refusal carries an actionable hint instead of the raw mode text only', async () => {
|
|
1034
|
+
const mounted = mountPlugin({
|
|
1035
|
+
services: {
|
|
1036
|
+
fs: {
|
|
1037
|
+
async resolve(path) {
|
|
1038
|
+
return { targetKey: path, displayPath: path }
|
|
1039
|
+
},
|
|
1040
|
+
async readText() {
|
|
1041
|
+
throw new Error('not found')
|
|
1042
|
+
},
|
|
1043
|
+
async writeText() {
|
|
1044
|
+
throw new Error('cannot write "/x": file access denied under workspace-write mode')
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
})
|
|
1049
|
+
const exec = sessionExec('/workspace/desk')
|
|
1050
|
+
await mounted.tools.get('scout_start').execute(startArgs('denied'), exec)
|
|
1051
|
+
const exported = await mounted.tools.get('scout_export').execute({
|
|
1052
|
+
caseId: 'denied',
|
|
1053
|
+
targetDir: '/workspace/desk/cases/denied',
|
|
1054
|
+
}, exec)
|
|
1055
|
+
assert.equal(exported.persisted, false)
|
|
1056
|
+
assert.match(exported.error, /sandbox policy/)
|
|
1057
|
+
assert.match(exported.error, /writable path/)
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
test('re-ingesting a URL upgrades its metadata instead of silently dropping it', async () => {
|
|
1061
|
+
const mounted = mountPlugin({ services: { fs: mockFs() } })
|
|
1062
|
+
const exec = execFor()
|
|
1063
|
+
await mounted.tools.get('scout_start').execute(startArgs('upgrade'), exec)
|
|
1064
|
+
// First pass: the same official URL was classified as a generic page.
|
|
1065
|
+
await mounted.tools.get('scout_ingest').execute({
|
|
1066
|
+
caseId: 'upgrade',
|
|
1067
|
+
itemsJson: JSON.stringify([{
|
|
1068
|
+
url: 'https://www.gsxt.gov.cn/registry/upgrade-1',
|
|
1069
|
+
title: 'Registry record',
|
|
1070
|
+
sourceType: 'other',
|
|
1071
|
+
evidenceLevel: 'E2',
|
|
1072
|
+
}]),
|
|
1073
|
+
}, exec)
|
|
1074
|
+
// Second pass states the authoritative category + level for the same URL.
|
|
1075
|
+
const upgraded = await mounted.tools.get('scout_ingest').execute({
|
|
1076
|
+
caseId: 'upgrade',
|
|
1077
|
+
itemsJson: JSON.stringify([{
|
|
1078
|
+
url: 'https://www.gsxt.gov.cn/registry/upgrade-1',
|
|
1079
|
+
title: 'Registry record',
|
|
1080
|
+
sourceType: 'company_registry',
|
|
1081
|
+
evidenceLevel: 'E3',
|
|
1082
|
+
}]),
|
|
1083
|
+
}, exec)
|
|
1084
|
+
assert.equal(upgraded.counts.sources, 1, 'the URL is reused, not duplicated')
|
|
1085
|
+
const events = mounted.ctx.fs ? [] : []
|
|
1086
|
+
const report = await mounted.tools.get('scout_report').execute({ caseId: 'upgrade' }, exec)
|
|
1087
|
+
assert.match(report.report, /工商登记 \| 官方\/权威来源/)
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
test('an E3 upgrade still has to satisfy the authority policy', async () => {
|
|
1091
|
+
const mounted = mountPlugin({ services: { fs: mockFs() } })
|
|
1092
|
+
const exec = execFor()
|
|
1093
|
+
await mounted.tools.get('scout_start').execute(startArgs('upgrade-denied'), exec)
|
|
1094
|
+
await mounted.tools.get('scout_ingest').execute({
|
|
1095
|
+
caseId: 'upgrade-denied',
|
|
1096
|
+
itemsJson: JSON.stringify([{ url: 'https://example.com/page', sourceType: 'other', evidenceLevel: 'E2' }]),
|
|
1097
|
+
}, exec)
|
|
1098
|
+
const refused = await mounted.tools.get('scout_ingest').execute({
|
|
1099
|
+
caseId: 'upgrade-denied',
|
|
1100
|
+
itemsJson: JSON.stringify([{
|
|
1101
|
+
url: 'https://example.com/page', sourceType: 'company_registry', evidenceLevel: 'E3',
|
|
1102
|
+
}]),
|
|
1103
|
+
}, exec)
|
|
1104
|
+
assert.match(refused.errors[0].error, /E3 sources require a supported registry/)
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
test('re-running the same ingest batch does not duplicate claims', async () => {
|
|
1108
|
+
const mounted = mountPlugin({ services: { fs: mockFs() } })
|
|
1109
|
+
const exec = execFor()
|
|
1110
|
+
await mounted.tools.get('scout_start').execute(startArgs('dedupe'), exec)
|
|
1111
|
+
const batch = JSON.stringify([{
|
|
1112
|
+
url: 'https://www.qixin.com/brand/dedupe',
|
|
1113
|
+
title: 'Aggregator page',
|
|
1114
|
+
claim: { text: 'The company was founded in 2021.', dimension: 'company_and_founder' },
|
|
1115
|
+
}])
|
|
1116
|
+
const first = await mounted.tools.get('scout_ingest').execute({ caseId: 'dedupe', itemsJson: batch }, exec)
|
|
1117
|
+
const second = await mounted.tools.get('scout_ingest').execute({ caseId: 'dedupe', itemsJson: batch }, exec)
|
|
1118
|
+
assert.equal(first.counts.claims, 1)
|
|
1119
|
+
assert.equal(second.counts.claims, 1, 'the ledger does not double on a retried batch')
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
test('a new case still tells the model what to do next', async () => {
|
|
1123
|
+
const mounted = mountPlugin({ services: { fs: mockFs() } })
|
|
1124
|
+
const started = await mounted.tools.get('scout_start').execute(startArgs('next-actions'), execFor())
|
|
1125
|
+
assert.ok(started.nextActions.length >= 1)
|
|
1126
|
+
assert.match(started.nextActions.join(' '), /scout_search|scout_ingest/)
|
|
1127
|
+
const rendered = renderedText(renderOutput(mounted, 'scout_start', started))
|
|
1128
|
+
assert.match(rendered, /^next: /m)
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
test('start refuses to overwrite a completed export unless overwrite is explicit', async () => {
|
|
1132
|
+
const store = new Map()
|
|
1133
|
+
const first = mountPlugin({ services: { fs: mockFs(store) } })
|
|
1134
|
+
const exec = execFor()
|
|
1135
|
+
await first.tools.get('scout_start').execute(startArgs('guarded'), exec)
|
|
1136
|
+
await first.tools.get('scout_export').execute({ caseId: 'guarded' }, exec)
|
|
1137
|
+
|
|
1138
|
+
const second = mountPlugin({ services: { fs: mockFs(store) } })
|
|
1139
|
+
await assert.rejects(
|
|
1140
|
+
() => second.tools.get('scout_start').execute(startArgs('guarded'), exec),
|
|
1141
|
+
/completed export already exists/,
|
|
1142
|
+
)
|
|
1143
|
+
const replaced = await second.tools.get('scout_start').execute(startArgs('guarded', { overwrite: true }), exec)
|
|
1144
|
+
assert.equal(replaced.ok, true)
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
test('start validates the case id and required labels', async () => {
|
|
1148
|
+
const mounted = mountPlugin({ services: { fs: mockFs() } })
|
|
1149
|
+
const exec = execFor()
|
|
1150
|
+
await assert.rejects(
|
|
1151
|
+
() => mounted.tools.get('scout_start').execute(startArgs('../escape/x'), exec),
|
|
1152
|
+
/Invalid caseId/,
|
|
1153
|
+
)
|
|
1154
|
+
await assert.rejects(
|
|
1155
|
+
() => mounted.tools.get('scout_start').execute(startArgs('blank-company', { companyName: ' ' }), exec),
|
|
1156
|
+
/companyName must not be blank/,
|
|
1157
|
+
)
|
|
1158
|
+
await assert.rejects(
|
|
1159
|
+
() => mounted.tools.get('scout_start').execute(startArgs('blank-role', { roleTitle: '' }), exec),
|
|
1160
|
+
/roleTitle must not be blank/,
|
|
1161
|
+
)
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
test('identity verification reaches a labelled tier and records per-field provenance', async () => {
|
|
1165
|
+
const mounted = mountPlugin({ services: { fs: mockFs() } })
|
|
1166
|
+
const exec = execFor()
|
|
1167
|
+
await mounted.tools.get('scout_start').execute(startArgs('tier'), exec)
|
|
1168
|
+
await mounted.tools.get('scout_ingest').execute({
|
|
1169
|
+
caseId: 'tier',
|
|
1170
|
+
itemsJson: JSON.stringify([
|
|
1171
|
+
{ url: 'https://www.qixin.com/brand/tier-1', title: 'Registry aggregator A', sourceType: 'public_database', evidenceLevel: 'E2' },
|
|
1172
|
+
{ url: 'https://apps.apple.com/cn/app/tier/id1', title: 'App Store listing', sourceType: 'company_official', evidenceLevel: 'E2' },
|
|
1173
|
+
{ url: 'https://www.szns.gov.cn/news/tier-1', title: 'Government event report', sourceType: 'regulator', evidenceLevel: 'E3' },
|
|
1174
|
+
]),
|
|
1175
|
+
}, exec)
|
|
1176
|
+
|
|
1177
|
+
// A government page that only mentions the company cannot verify the
|
|
1178
|
+
// registration number: with field provenance the tier is corroborated.
|
|
1179
|
+
const corroborated = await mounted.tools.get('scout_verify_identity').execute({
|
|
1180
|
+
caseId: 'tier',
|
|
1181
|
+
legalEntity: 'Example Co Ltd',
|
|
1182
|
+
registrationNumber: '91440300TEST000001',
|
|
1183
|
+
registeredRegion: 'Shenzhen',
|
|
1184
|
+
legalRepresentative: 'Example Person',
|
|
1185
|
+
brandRelationship: 'Operating entity',
|
|
1186
|
+
sourceIds: 'src-1,src-2,src-3',
|
|
1187
|
+
fieldSources: JSON.stringify({
|
|
1188
|
+
legalEntity: 'src-1,src-3',
|
|
1189
|
+
registrationNumber: 'src-1',
|
|
1190
|
+
registeredRegion: 'src-1',
|
|
1191
|
+
legalRepresentative: 'src-1',
|
|
1192
|
+
brandRelationship: 'src-2',
|
|
1193
|
+
}),
|
|
1194
|
+
}, exec)
|
|
1195
|
+
assert.equal(corroborated.identity, 'corroborated')
|
|
1196
|
+
const report = await mounted.tools.get('scout_report').execute({ caseId: 'tier' }, exec)
|
|
1197
|
+
assert.match(report.report, /## 1\. 公司与主体/)
|
|
1198
|
+
assert.match(report.report, /已交叉核验/)
|
|
1199
|
+
assert.match(report.report, /统一社会信用代码=src-1/)
|
|
1200
|
+
assert.match(report.report, /已交叉核验:多个独立来源/)
|
|
1201
|
+
|
|
1202
|
+
// A second independent E2 source can support the registration number, but
|
|
1203
|
+
// only an E3 registration source upgrades the tier to verified.
|
|
1204
|
+
await mounted.tools.get('scout_ingest').execute({
|
|
1205
|
+
caseId: 'tier',
|
|
1206
|
+
itemsJson: JSON.stringify([{
|
|
1207
|
+
url: 'https://www.gsxt.gov.cn/registry/tier-2', title: 'Official registry record',
|
|
1208
|
+
sourceType: 'company_registry', evidenceLevel: 'E3',
|
|
1209
|
+
}]),
|
|
1210
|
+
}, exec)
|
|
1211
|
+
const verified = await mounted.tools.get('scout_verify_identity').execute({
|
|
1212
|
+
caseId: 'tier',
|
|
1213
|
+
legalEntity: 'Example Co Ltd',
|
|
1214
|
+
registrationNumber: '91440300TEST000001',
|
|
1215
|
+
registeredRegion: 'Shenzhen',
|
|
1216
|
+
legalRepresentative: 'Example Person',
|
|
1217
|
+
brandRelationship: 'Operating entity',
|
|
1218
|
+
sourceIds: 'src-1,src-2,src-3,src-4',
|
|
1219
|
+
fieldSources: JSON.stringify({
|
|
1220
|
+
legalEntity: 'src-1,src-3',
|
|
1221
|
+
registrationNumber: 'src-4',
|
|
1222
|
+
registeredRegion: 'src-4',
|
|
1223
|
+
legalRepresentative: 'src-1',
|
|
1224
|
+
brandRelationship: 'src-2',
|
|
1225
|
+
}),
|
|
1226
|
+
}, exec)
|
|
1227
|
+
assert.equal(verified.identity, 'verified')
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
test('import rejects invalid evidence enums instead of casting them through', () => {
|
|
1231
|
+
const files = exportCaseFiles(createCase({ caseId: 'import-enum', companyName: 'Example Co', roleTitle: 'HR Head' }))
|
|
1232
|
+
const sources = JSON.parse(files['sources.json'])
|
|
1233
|
+
sources.push({ sourceId: 'bad', type: 'other', title: 'bad', url: null, capturedAt: null, evidenceLevel: 'E9', status: 'captured' })
|
|
1234
|
+
assert.throws(
|
|
1235
|
+
() => importCaseFromFiles({ ...files, 'sources.json': JSON.stringify(sources) }),
|
|
1236
|
+
/Invalid evidence level/,
|
|
1237
|
+
)
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
test('scout_search summarises new results by title and reuses them silently on repeat', async () => {
|
|
1241
|
+
const web = {
|
|
1242
|
+
async search() {
|
|
1243
|
+
return {
|
|
1244
|
+
sources: [
|
|
1245
|
+
{ url: 'https://www.qixin.com/brand/render-1', title: '启信宝页面' },
|
|
1246
|
+
{ url: 'https://apps.apple.com/cn/app/render/id1', title: 'App Store 页面' },
|
|
1247
|
+
],
|
|
1248
|
+
truncated: false,
|
|
1249
|
+
}
|
|
1250
|
+
},
|
|
1251
|
+
}
|
|
1252
|
+
const mounted = mountPlugin({ services: { fs: mockFs(), web } })
|
|
1253
|
+
const exec = execFor()
|
|
1254
|
+
await mounted.tools.get('scout_start').execute(startArgs('search-render'), exec)
|
|
1255
|
+
const first = await mounted.tools.get('scout_search').execute({
|
|
1256
|
+
caseId: 'search-render', query: 'render', limit: 5,
|
|
1257
|
+
}, exec)
|
|
1258
|
+
const firstText = renderedText(renderOutput(mounted, 'scout_search', first))
|
|
1259
|
+
assert.match(firstText, /new: 启信宝页面 · (www\.)?qixin\.com/)
|
|
1260
|
+
assert.match(firstText, /App Store 页面 · apps\.apple\.com/)
|
|
1261
|
+
assert.equal(first.counts.sources, 2)
|
|
1262
|
+
|
|
1263
|
+
const second = await mounted.tools.get('scout_search').execute({
|
|
1264
|
+
caseId: 'search-render', query: 'render', limit: 5,
|
|
1265
|
+
}, exec)
|
|
1266
|
+
assert.equal(second.counts.sources, 2, 'the same URLs are reused')
|
|
1267
|
+
assert.doesNotMatch(renderedText(renderOutput(mounted, 'scout_search', second)), /^new: /m)
|
|
1268
|
+
})
|
|
1269
|
+
|
|
1270
|
+
test('the /scout export command honours targetDir and cold-start commands rehydrate', async () => {
|
|
1271
|
+
const store = new Map()
|
|
1272
|
+
const registrations = []
|
|
1273
|
+
const mountWithCommands = () => mountPlugin({
|
|
1274
|
+
config: { scoutDir: '/tmp/cmd-scout' },
|
|
1275
|
+
services: {
|
|
1276
|
+
fs: mockFs(store),
|
|
1277
|
+
commands: {
|
|
1278
|
+
register(definition) {
|
|
1279
|
+
registrations.push(definition)
|
|
1280
|
+
return () => undefined
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
},
|
|
1284
|
+
})
|
|
1285
|
+
const agent = { id: 'session-a', session: { header: { cwd: '/workspace' } } }
|
|
1286
|
+
const exec = { agent }
|
|
1287
|
+
|
|
1288
|
+
const first = mountWithCommands()
|
|
1289
|
+
await first.tools.get('scout_start').execute(startArgs('cmd-export'), exec)
|
|
1290
|
+
const handlerOf = () => registrations[registrations.length - 1].handler
|
|
1291
|
+
const exported = await handlerOf()({ rawInput: 'export cmd-export /tmp/cmd-out', agent })
|
|
1292
|
+
assert.equal(exported.kind, 'success')
|
|
1293
|
+
assert.match(exported.text, /exported to \/tmp\/cmd-out/)
|
|
1294
|
+
assert.ok(store.has('/tmp/cmd-out/export.complete.json'), 'targetDir token is honoured')
|
|
1295
|
+
|
|
1296
|
+
// A second case exported with the default scoutDir must come back from disk
|
|
1297
|
+
// on a cold start; the command path used to answer "Unknown case".
|
|
1298
|
+
await first.tools.get('scout_start').execute(startArgs('cmd-cold'), exec)
|
|
1299
|
+
const defaultExport = await handlerOf()({ rawInput: 'export cmd-cold', agent })
|
|
1300
|
+
assert.equal(defaultExport.kind, 'success')
|
|
1301
|
+
assert.match(defaultExport.text, /exported to \/tmp\/cmd-scout\/cmd-cold/)
|
|
1302
|
+
|
|
1303
|
+
registrations.length = 0
|
|
1304
|
+
mountWithCommands()
|
|
1305
|
+
const status = await handlerOf()({ rawInput: 'status cmd-cold', agent })
|
|
1306
|
+
assert.equal(status.kind, 'success')
|
|
1307
|
+
assert.match(status.text, /identity needs_verification/)
|
|
1308
|
+
assert.match(status.text, /· dsh-scout /)
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
test('scout_add_source rejects a URL already registered under another id', async () => {
|
|
1312
|
+
const mounted = mountPlugin({ services: { fs: mockFs() } })
|
|
1313
|
+
const exec = execFor()
|
|
1314
|
+
await mounted.tools.get('scout_start').execute(startArgs('dupe-url'), exec)
|
|
1315
|
+
const addSource = mounted.tools.get('scout_add_source')
|
|
1316
|
+
await addSource.execute({
|
|
1317
|
+
caseId: 'dupe-url', sourceId: 'a', sourceType: 'other', title: 'A',
|
|
1318
|
+
url: 'https://example.com/x', evidenceLevel: 'E2',
|
|
1319
|
+
}, exec)
|
|
1320
|
+
await assert.rejects(
|
|
1321
|
+
() => addSource.execute({
|
|
1322
|
+
caseId: 'dupe-url', sourceId: 'b', sourceType: 'public_database', title: 'B',
|
|
1323
|
+
url: 'https://example.com/x?utm_source=somewhere', evidenceLevel: 'E2',
|
|
1324
|
+
}, exec),
|
|
1325
|
+
/URL already registered as a/,
|
|
1326
|
+
)
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
test('import rejects invalid claim enums', () => {
|
|
1330
|
+
const files = exportCaseFiles(createCase({ caseId: 'import-claim-enum', companyName: 'Example Co', roleTitle: 'HR Head' }))
|
|
1331
|
+
const claims = JSON.parse(files['claims.json'])
|
|
1332
|
+
claims.push({
|
|
1333
|
+
claimId: 'bad', text: 'Bad claim', status: 'not-a-status', evidenceLevel: 'E2',
|
|
1334
|
+
dimension: 'risk', impact: 'material', sourceIds: [], confidenceNote: 'x', nextAction: 'y',
|
|
1335
|
+
})
|
|
1336
|
+
assert.throws(
|
|
1337
|
+
() => importCaseFromFiles({ ...files, 'claims.json': JSON.stringify(claims) }),
|
|
1338
|
+
/Invalid claim status/,
|
|
1339
|
+
)
|
|
1340
|
+
})
|