dsh-hitl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/README.zh.md +490 -0
- package/client.js +1918 -0
- package/cordis.patch.yml +17 -0
- package/docs/01-panel-cn.png +0 -0
- package/docs/01-panel-en.png +0 -0
- package/docs/02-fields-cn.png +0 -0
- package/docs/02-fields-en.png +0 -0
- package/docs/03-subagent-cn.png +0 -0
- package/docs/03-subagent-en.png +0 -0
- package/docs/04-overlay-cn.png +0 -0
- package/docs/04-overlay-en.png +0 -0
- package/icon.svg +6 -0
- package/index.js +629 -0
- package/lib/fields.js +244 -0
- package/lib/pending.js +217 -0
- package/lib/protocol.js +182 -0
- package/lib/resolve.js +302 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -0
- package/tests/client.test.js +818 -0
- package/tests/docs.test.js +116 -0
- package/tests/fields.test.js +269 -0
- package/tests/manifest.test.js +61 -0
- package/tests/pending.test.js +240 -0
- package/tests/protocol.test.js +117 -0
- package/tests/resolve.test.js +162 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Guards the two READMEs against the drift this repository keeps fixing by hand:
|
|
9
|
+
* a screenshot that moved, a language link that points nowhere, and an appendix
|
|
10
|
+
* whose line counts or test counts describe a file as it was three edits ago.
|
|
11
|
+
*
|
|
12
|
+
* The counts exist for readers deciding where to start reading; a stale number
|
|
13
|
+
* is a small lie that costs a reader real time, so each one is asserted against
|
|
14
|
+
* the filesystem instead of trusted.
|
|
15
|
+
*/
|
|
16
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
17
|
+
|
|
18
|
+
/** The English README is the package's front page; the Chinese one is its twin. */
|
|
19
|
+
const READMES = [
|
|
20
|
+
{ language: 'en', file: 'README.md', other: 'README.zh.md', suffix: '-en' },
|
|
21
|
+
{ language: 'zh', file: 'README.zh.md', other: 'README.md', suffix: '-cn' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const read = relative => readFileSync(join(root, relative), 'utf8')
|
|
25
|
+
/** Line count by the `wc -l` convention: the number of newline characters. */
|
|
26
|
+
const lineCount = relative => read(relative).split('\n').length - 1
|
|
27
|
+
|
|
28
|
+
/** Every repository path a basename could name, so the appendix may spell either. */
|
|
29
|
+
function locate(name) {
|
|
30
|
+
const candidates = [name, join('lib', name), join('tests', name), join('locale', name), join('docs', name)]
|
|
31
|
+
return candidates.find(candidate => existsSync(join(root, candidate))) ?? name
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The file-structure appendix: the one text block that lists `client.js` and its size. */
|
|
35
|
+
function appendixTree(text) {
|
|
36
|
+
const blocks = text.split('```').filter((_block, index) => index % 2 === 1)
|
|
37
|
+
const tree = blocks.find(block => block.includes('client.js') && block.includes('index.js'))
|
|
38
|
+
assert.notEqual(tree, undefined, 'the appendix file-structure block is missing')
|
|
39
|
+
const rows = new Map()
|
|
40
|
+
const testCounts = new Map()
|
|
41
|
+
for (const line of tree.split('\n')) {
|
|
42
|
+
const row = /^[│├└─\s]*([\w.\-]+\.(?:js|json|yml|svg|md))\s+(\d+)\b/.exec(line)
|
|
43
|
+
if (row === null) continue
|
|
44
|
+
rows.set(row[1], Number(row[2]))
|
|
45
|
+
const tests = /[((](\d+)[))]/.exec(line)
|
|
46
|
+
if (tests !== null && row[1].endsWith('.test.js')) testCounts.set(row[1], Number(tests[1]))
|
|
47
|
+
}
|
|
48
|
+
return { rows, testCounts }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('docs: the English and Chinese READMEs', () => {
|
|
52
|
+
it('links each language to the other', () => {
|
|
53
|
+
for (const readme of READMES) {
|
|
54
|
+
const text = read(readme.file)
|
|
55
|
+
assert.equal(text.includes(`](${readme.other})`), true, `${readme.file} must link to ${readme.other}`)
|
|
56
|
+
assert.equal(existsSync(join(root, readme.other)), true, `${readme.other} must exist`)
|
|
57
|
+
}
|
|
58
|
+
// The language line belongs in the header, before the first section.
|
|
59
|
+
for (const readme of READMES) {
|
|
60
|
+
const header = read(readme.file).split('\n').slice(0, 8).join('\n')
|
|
61
|
+
assert.equal(header.includes(readme.other), true, `${readme.file} must offer the switch near the top`)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('shows every screenshot, and only its own language', () => {
|
|
66
|
+
const referenced = new Set()
|
|
67
|
+
const available = new Set(readdirSync(join(root, 'docs')))
|
|
68
|
+
for (const readme of READMES) {
|
|
69
|
+
const images = [...read(readme.file).matchAll(/!\[[^\]]*\]\((docs\/[^)]+)\)/g)].map(match => match[1])
|
|
70
|
+
assert.equal(images.length, 4, `${readme.file} must place all four screenshots`)
|
|
71
|
+
for (const image of images) {
|
|
72
|
+
const name = image.slice('docs/'.length)
|
|
73
|
+
assert.equal(available.has(name), true, `${readme.file} references a missing screenshot: ${image}`)
|
|
74
|
+
assert.equal(name.endsWith(`${readme.suffix}.png`), true, `${readme.file} must use its ${readme.suffix} screenshots`)
|
|
75
|
+
referenced.add(name)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const name of available) {
|
|
79
|
+
assert.equal(referenced.has(name), true, `docs/${name} is shipped but never shown`)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps both file-structure appendixes identical and true to disk', () => {
|
|
84
|
+
const trees = READMES.map(readme => appendixTree(read(readme.file)))
|
|
85
|
+
const [english, chinese] = trees
|
|
86
|
+
assert.deepEqual([...chinese.rows], [...english.rows], 'the two appendixes must declare the same sizes')
|
|
87
|
+
for (const [name, claimed] of english.rows) {
|
|
88
|
+
const path = locate(name)
|
|
89
|
+
assert.equal(existsSync(join(root, path)), true, `the appendix lists a file that is gone: ${name}`)
|
|
90
|
+
assert.equal(lineCount(path), claimed, `${path} is ${lineCount(path)} lines, the appendix says ${claimed}`)
|
|
91
|
+
}
|
|
92
|
+
// The READMEs are listed without a size on purpose: their own length would
|
|
93
|
+
// change with every edit that keeps the number honest, which has no fixed point.
|
|
94
|
+
const tree = read('README.md').split('```').filter((_b, i) => i % 2 === 1).find(b => b.includes('client.js'))
|
|
95
|
+
for (const name of ['README.md', 'README.zh.md']) {
|
|
96
|
+
assert.equal(tree.includes(name), true, `the appendix must list ${name}`)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('keeps the per-file test counts honest', () => {
|
|
101
|
+
for (const readme of READMES) {
|
|
102
|
+
const { testCounts } = appendixTree(read(readme.file))
|
|
103
|
+
assert.equal(testCounts.size, readdirSync(join(root, 'tests')).filter(name => name.endsWith('.test.js')).length)
|
|
104
|
+
for (const [name, claimed] of testCounts) {
|
|
105
|
+
const declared = (read(join('tests', name)).match(/^\s*it\(/gm) ?? []).length
|
|
106
|
+
assert.equal(claimed, declared, `${name}: the appendix says ${claimed} cases, the file declares ${declared}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('keeps the screenshot folder shipped', () => {
|
|
112
|
+
const manifest = JSON.parse(read('package.json'))
|
|
113
|
+
assert.equal(manifest.files.includes('docs'), true, '`docs` must be published with the screenshots')
|
|
114
|
+
assert.equal(manifest.files.includes('README.zh.md'), true, 'the Chinese README must be published too')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
REASON_PREFIX, buildFields, buildRequest, capReason, defaultFieldSpecs, describeDecision,
|
|
6
|
+
inferRender, isRevision, renderValue, revisionContext, revisionText,
|
|
7
|
+
} from '../lib/fields.js'
|
|
8
|
+
import { normalizeMount } from '../lib/resolve.js'
|
|
9
|
+
|
|
10
|
+
/** Normalize one mount through the same path the host half uses. */
|
|
11
|
+
function mountOf(input) {
|
|
12
|
+
const normalized = normalizeMount({ tool: 'demo', ...input }, 'test')
|
|
13
|
+
assert.equal(normalized.ok, true, normalized.warnings.join('; '))
|
|
14
|
+
return normalized.mount
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One pending execution with the schema shape `tools/pre-execute` hands over. */
|
|
18
|
+
function executionOf(argumentsValue, schema) {
|
|
19
|
+
return {
|
|
20
|
+
name: 'demo',
|
|
21
|
+
callId: 'call_1',
|
|
22
|
+
arguments: argumentsValue,
|
|
23
|
+
...(schema === undefined ? {} : { schema: { name: 'demo', parameters: schema } }),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('fields: renderValue and inferRender', () => {
|
|
28
|
+
it('passes strings through and pretty-prints other values', () => {
|
|
29
|
+
assert.equal(renderValue('ls -la'), 'ls -la')
|
|
30
|
+
assert.equal(renderValue({ a: 1 }), '{\n "a": 1\n}')
|
|
31
|
+
assert.equal(renderValue(3), '3')
|
|
32
|
+
assert.equal(renderValue(undefined), '(undefined)')
|
|
33
|
+
assert.equal(renderValue({ self: 1n }), '[object Object]')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('defaults to the markdown box', () => {
|
|
37
|
+
assert.equal(inferRender(), 'markdown')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('fields: defaultFieldSpecs', () => {
|
|
42
|
+
it('uses every parameter in argument order', () => {
|
|
43
|
+
assert.deepEqual(defaultFieldSpecs(executionOf({ b: 1, a: 2 })), [{ param: 'b' }, { param: 'a' }])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('falls back to one arguments field for empty or scalar arguments', () => {
|
|
47
|
+
assert.deepEqual(defaultFieldSpecs(executionOf({})), [{ param: '(arguments)', render: 'json' }])
|
|
48
|
+
assert.deepEqual(defaultFieldSpecs(executionOf('hello')), [{ param: '(arguments)', render: 'json' }])
|
|
49
|
+
assert.deepEqual(defaultFieldSpecs(executionOf([1, 2])), [{ param: '(arguments)', render: 'json' }])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('fields: buildFields defaults', () => {
|
|
54
|
+
it('titles each field with the parameter key and takes schema help when present', () => {
|
|
55
|
+
const fields = buildFields(executionOf({ command: 'ls -la', cwd: '/tmp' }, {
|
|
56
|
+
command: { title: '命令', description: '要执行的命令' },
|
|
57
|
+
cwd: { description: '工作目录' },
|
|
58
|
+
}), mountOf({}))
|
|
59
|
+
assert.deepEqual(fields, [
|
|
60
|
+
{
|
|
61
|
+
param: 'command',
|
|
62
|
+
title: '命令',
|
|
63
|
+
description: '要执行的命令',
|
|
64
|
+
render: 'markdown',
|
|
65
|
+
editable: true,
|
|
66
|
+
labels: [],
|
|
67
|
+
value: 'ls -la',
|
|
68
|
+
truncated: false,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
param: 'cwd',
|
|
72
|
+
title: 'cwd',
|
|
73
|
+
description: '工作目录',
|
|
74
|
+
render: 'markdown',
|
|
75
|
+
editable: true,
|
|
76
|
+
labels: [],
|
|
77
|
+
value: '/tmp',
|
|
78
|
+
truncated: false,
|
|
79
|
+
},
|
|
80
|
+
])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('renders non-string values as JSON text in an editable markdown box', () => {
|
|
84
|
+
const [field] = buildFields(executionOf({ count: 3 }), mountOf({}))
|
|
85
|
+
assert.equal(field.render, 'markdown')
|
|
86
|
+
assert.equal(field.editable, true)
|
|
87
|
+
assert.equal(field.value, '3')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('shows the whole scalar argument when arguments are not a record', () => {
|
|
91
|
+
const [field] = buildFields(executionOf('hello'), mountOf({}))
|
|
92
|
+
assert.equal(field.param, '(arguments)')
|
|
93
|
+
assert.equal(field.value, 'hello')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('truncates a long value and says so', () => {
|
|
97
|
+
const [field] = buildFields(executionOf({ body: 'x'.repeat(50) }), mountOf({ maxFieldChars: 5 }))
|
|
98
|
+
assert.equal(field.truncated, true)
|
|
99
|
+
assert.equal(field.value.startsWith('xxxxx'), true)
|
|
100
|
+
assert.match(field.value, /已截断,共 50 字/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('sends no value for a hidden field', () => {
|
|
104
|
+
const [field] = buildFields(executionOf({ secret: 'shh' }), mountOf({ fields: [{ param: 'secret', render: 'hidden' }] }))
|
|
105
|
+
assert.deepEqual(field, { param: 'secret', title: 'secret', render: 'hidden', editable: false, labels: [], value: '', truncated: false })
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('fields: configured fields', () => {
|
|
110
|
+
it('shows exactly the configured parameters', () => {
|
|
111
|
+
const fields = buildFields(executionOf({ file_path: '/a', content: 'x', extra: 'y' }), mountOf({
|
|
112
|
+
fields: ['file_path', { param: 'content', render: 'json' }],
|
|
113
|
+
}))
|
|
114
|
+
assert.deepEqual(fields.map(field => field.param), ['file_path', 'content'])
|
|
115
|
+
assert.equal(fields[1].render, 'json')
|
|
116
|
+
assert.equal(fields[1].editable, false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('pairs two parameters into one read-only diff and drops them from the list', () => {
|
|
120
|
+
const fields = buildFields(executionOf({
|
|
121
|
+
file_path: '/a.txt', old_string: 'one', new_string: 'two', extra: 'z',
|
|
122
|
+
}), mountOf({ diff: { path: 'file_path', before: 'old_string', after: 'new_string' } }))
|
|
123
|
+
assert.deepEqual(fields.map(field => field.param), ['file_path', 'extra', 'old_string→new_string'])
|
|
124
|
+
const diff = fields[2]
|
|
125
|
+
assert.equal(diff.render, 'diff')
|
|
126
|
+
assert.equal(diff.editable, false)
|
|
127
|
+
assert.deepEqual(diff.diff, { path: '/a.txt', oldText: 'one', newText: 'two' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('supports a field-level diff without a mount-level one', () => {
|
|
131
|
+
const fields = buildFields(executionOf({ old: 'a', next: 'b' }), mountOf({
|
|
132
|
+
fields: [{ param: 'content', render: 'diff', diff: { before: 'old', after: 'next' } }],
|
|
133
|
+
}))
|
|
134
|
+
assert.equal(fields.length, 1)
|
|
135
|
+
assert.deepEqual(fields[0].diff, { path: undefined, oldText: 'a', newText: 'b' })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('falls back to the arguments field when a configured list selects nothing', () => {
|
|
139
|
+
const fields = buildFields(executionOf({ a: 1 }), mountOf({ fields: [] }))
|
|
140
|
+
assert.deepEqual(fields, [{ param: '(arguments)', title: '(arguments)', render: 'json', editable: false, labels: [], value: '{}', truncated: false }])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('fields: buildRequest', () => {
|
|
145
|
+
it('carries the call identity, the defaults, and no empty title', () => {
|
|
146
|
+
const request = buildRequest({
|
|
147
|
+
execution: executionOf({ command: 'ls' }), mount: mountOf({}), sessionId: 's1', id: 'r1', now: 1000,
|
|
148
|
+
})
|
|
149
|
+
assert.deepEqual(request, {
|
|
150
|
+
id: 'r1',
|
|
151
|
+
sessionId: 's1',
|
|
152
|
+
toolName: 'demo',
|
|
153
|
+
callId: 'call_1',
|
|
154
|
+
layout: 'stacked',
|
|
155
|
+
labels: [],
|
|
156
|
+
fields: [{
|
|
157
|
+
param: 'command', title: 'command', render: 'markdown', editable: true, labels: [], value: 'ls', truncated: false,
|
|
158
|
+
}],
|
|
159
|
+
buttons: { feedback: { enabled: false, required: false } },
|
|
160
|
+
countdown: null,
|
|
161
|
+
createdAt: 1000,
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('carries a countdown, label overrides, feedback settings, and a title', () => {
|
|
166
|
+
const request = buildRequest({
|
|
167
|
+
execution: executionOf({ command: 'ls' }),
|
|
168
|
+
mount: mountOf({
|
|
169
|
+
title: '确认执行',
|
|
170
|
+
labels: ['不可撤销'],
|
|
171
|
+
countdown: { seconds: 30 },
|
|
172
|
+
reject: { feedback: true, feedbackPrompt: '为什么?', requireFeedback: true },
|
|
173
|
+
labels2: undefined,
|
|
174
|
+
}),
|
|
175
|
+
sessionId: 's1',
|
|
176
|
+
id: 'r1',
|
|
177
|
+
now: 0,
|
|
178
|
+
})
|
|
179
|
+
assert.equal(request.title, '确认执行')
|
|
180
|
+
assert.deepEqual(request.labels, ['不可撤销'])
|
|
181
|
+
assert.deepEqual(request.countdown, { remainingMs: 30000, action: 'reject', freezeOnInteract: true })
|
|
182
|
+
assert.deepEqual(request.buttons.feedback, { enabled: true, prompt: '为什么?', required: true })
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('carries the button label overrides a mount configured', () => {
|
|
186
|
+
const request = buildRequest({
|
|
187
|
+
execution: executionOf({ command: 'ls' }),
|
|
188
|
+
mount: mountOf({ buttons: { approve: '同意', modify: '修改', reject: '拒绝' } }),
|
|
189
|
+
sessionId: 's1',
|
|
190
|
+
id: 'r1',
|
|
191
|
+
})
|
|
192
|
+
assert.deepEqual(request.buttons, {
|
|
193
|
+
approve: '同意', modify: '修改', reject: '拒绝', feedback: { enabled: false, required: false },
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('omits a call id the execution does not have', () => {
|
|
198
|
+
const request = buildRequest({
|
|
199
|
+
execution: { name: 'demo', arguments: {} }, mount: mountOf({}), sessionId: 's1', id: 'r1',
|
|
200
|
+
})
|
|
201
|
+
assert.equal('callId' in request, false)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('fields: decisions back to the model', () => {
|
|
206
|
+
const request = buildRequest({
|
|
207
|
+
execution: executionOf({ command: 'ls' }),
|
|
208
|
+
mount: mountOf({ countdown: { seconds: 30, action: 'notify' } }),
|
|
209
|
+
sessionId: 's1',
|
|
210
|
+
id: 'r1',
|
|
211
|
+
now: 0,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('recognizes a revision', () => {
|
|
215
|
+
assert.equal(isRevision({ kind: 'modify', fields: [{ param: 'command', text: 'ls' }] }), true)
|
|
216
|
+
assert.equal(isRevision({ kind: 'approve' }), false)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('renders revision text per field', () => {
|
|
220
|
+
assert.equal(revisionText({ kind: 'modify', fields: [{ param: 'a', text: '1' }, { param: 'b', text: '2' }] }), '- a:\n1\n- b:\n2')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('reports a rejection with the user feedback verbatim', () => {
|
|
224
|
+
const reason = describeDecision(request, { kind: 'reject', feedback: '太危险' }, 'user')
|
|
225
|
+
assert.equal(reason.startsWith(`${REASON_PREFIX}: the user rejected tool "demo"`), true)
|
|
226
|
+
assert.equal(reason.includes('User feedback: 太危险'), true)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('omits the feedback sentence when the user typed none', () => {
|
|
230
|
+
assert.equal(describeDecision(request, { kind: 'reject' }, 'user').includes('User feedback'), false)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('turns a revision into a re-issue instruction', () => {
|
|
234
|
+
const reason = describeDecision(request, { kind: 'modify', fields: [{ param: 'command', text: 'rm -rf /' }] }, 'user')
|
|
235
|
+
assert.equal(reason.includes('Re-issue the call with these changes'), true)
|
|
236
|
+
assert.equal(reason.includes('- command:\nrm -rf /'), true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('distinguishes the three timeout policies', () => {
|
|
240
|
+
assert.equal(describeDecision(request, { kind: 'approve' }, 'timeout').includes("ran under the mount's timeout policy"), true)
|
|
241
|
+
assert.equal(describeDecision(request, { kind: 'reject' }, 'timeout').includes('Tell the user this call timed out'), true)
|
|
242
|
+
const strict = buildRequest({
|
|
243
|
+
execution: executionOf({ command: 'ls' }),
|
|
244
|
+
mount: mountOf({ countdown: { seconds: 10, action: 'reject' } }),
|
|
245
|
+
sessionId: 's1',
|
|
246
|
+
id: 'r2',
|
|
247
|
+
})
|
|
248
|
+
const reason = describeDecision(strict, { kind: 'reject' }, 'timeout')
|
|
249
|
+
assert.equal(reason.includes('did not run'), true)
|
|
250
|
+
assert.equal(reason.includes('timeout policy: reject'), true)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('reports withdrawal for abort and host settlement', () => {
|
|
254
|
+
assert.equal(describeDecision(request, { kind: 'cancel' }, 'abort').includes('withdrawn before the user answered'), true)
|
|
255
|
+
assert.equal(describeDecision(request, { kind: 'cancel' }, 'host').includes('withdrawn before the user answered'), true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('reports an approval that carried edits', () => {
|
|
259
|
+
const context = revisionContext(request, { kind: 'modify', fields: [{ param: 'command', text: 'ls -la' }] })
|
|
260
|
+
assert.equal(context.includes('approved tool "demo" after editing the proposal'), true)
|
|
261
|
+
assert.equal(context.includes('- command:\nls -la'), true)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('caps a very long model-facing text', () => {
|
|
265
|
+
const capped = capReason('x'.repeat(20000))
|
|
266
|
+
assert.equal(capped.length < 20000, true)
|
|
267
|
+
assert.equal(capped.includes('已截断'), true)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Guards the package files a DSH profile reads *about* this plugin rather than
|
|
9
|
+
* from it: the manifest the Loader resolves, and the `locale/*.json` resources
|
|
10
|
+
* the plugin list shows.
|
|
11
|
+
*
|
|
12
|
+
* The loader reads `parsed.meta.title` / `parsed.meta.description` from each
|
|
13
|
+
* language file (`app-boot` `package-meta.ts`), and it reaches those files only
|
|
14
|
+
* through the `exports` map. Get either wrong and the plugin still works while
|
|
15
|
+
* its card silently falls back to the bare package name — a defect nobody
|
|
16
|
+
* notices, which is exactly why it is asserted here.
|
|
17
|
+
*/
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const root = join(here, '..')
|
|
20
|
+
|
|
21
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
22
|
+
|
|
23
|
+
/** Language ids the shipped dictionaries and the browser half both carry. */
|
|
24
|
+
const LANGUAGES = ['en', 'zh']
|
|
25
|
+
|
|
26
|
+
describe('manifest: what a profile reads about this plugin', () => {
|
|
27
|
+
it('resolves the resources the Loader asks for by name', () => {
|
|
28
|
+
assert.equal(manifest.name, 'dsh-hitl')
|
|
29
|
+
assert.equal(typeof manifest.description, 'string')
|
|
30
|
+
assert.equal(manifest.exports['.'], './index.js')
|
|
31
|
+
assert.equal(manifest.exports['./client'], './client.js')
|
|
32
|
+
// The locale resources are subpaths: without the pattern the loader cannot
|
|
33
|
+
// resolve them at all, and the card title falls back to the package name.
|
|
34
|
+
assert.equal(manifest.exports['./locale/*.json'], './locale/*.json')
|
|
35
|
+
assert.equal(manifest.files.includes('locale'), true, 'the published files must carry the locale directory')
|
|
36
|
+
assert.equal(manifest.files.includes('client.js'), true)
|
|
37
|
+
assert.equal(manifest.files.includes('lib'), true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('declares the bundle patch and the browser half the bundle needs', () => {
|
|
41
|
+
assert.equal(manifest.dsh.bundle.patch, './cordis.patch.yml')
|
|
42
|
+
assert.equal(manifest.dsh.client.platform, 'web')
|
|
43
|
+
assert.equal(manifest.dsh.client.immediately, true)
|
|
44
|
+
for (const dependency of ['@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-session']) {
|
|
45
|
+
assert.equal(manifest.dsh.client.inject.includes(dependency), true, `${dependency} must stay injected`)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('gives every shipped language the meta envelope the loader reads', () => {
|
|
50
|
+
const files = readdirSync(join(root, 'locale')).filter(name => name.endsWith('.json'))
|
|
51
|
+
assert.deepEqual(files.map(name => name.slice(0, -5)).sort(), [...LANGUAGES].sort())
|
|
52
|
+
for (const language of LANGUAGES) {
|
|
53
|
+
const resource = JSON.parse(readFileSync(join(root, 'locale', `${language}.json`), 'utf8'))
|
|
54
|
+
assert.equal(typeof resource.meta, 'object', `locale/${language}.json must wrap its copy in "meta"`)
|
|
55
|
+
assert.equal(typeof resource.meta.title, 'string', `locale/${language}.json: meta.title`)
|
|
56
|
+
assert.equal(typeof resource.meta.description, 'string', `locale/${language}.json: meta.description`)
|
|
57
|
+
// A bare top-level title is the shape the loader ignores.
|
|
58
|
+
assert.equal(resource.title, undefined, `locale/${language}.json must not put title at the top level`)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
})
|