dsh-loadout 0.12.0 → 0.12.1
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 +5 -0
- package/index.js +9 -3
- package/package.json +1 -1
- package/scripts/client-smoke.mjs +8 -6
- package/scripts/selftest.mjs +125 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
「装备栏」是一个 DeepSeek Harness 插件:给每个对话选一套工具(套装),只把用得上的工具投给模型。
|
|
4
4
|
|
|
5
|
+
## 0.12.1
|
|
6
|
+
|
|
7
|
+
- **修卡死**:编辑(覆盖保存)、重命名或删除一个正在被对话使用的套装时,宿主会遍历「正在用它的对话」并逐个重新套用工具;这个遍历边改边往 `Map` 里重写条目,迭代器被塞回一个已经走过的键,于是永远走不完——表现是界面点下去没有任何反应、进程占满一个核。现在遍历快照(`[...state]`),编辑与删除都会立刻返回。
|
|
8
|
+
- 测试补齐验收表:`selftest` 新增「同名不同大小写/空格被拒绝」「预置名不能被用户套装占用」「连续改默认不影响已有对话」「删除套装报告并释放全部绑定」「删除默认套装同时清空默认」「改名不动绑定」「写盘不留临时文件」「预置也能当默认」八项断言;`client-smoke` 的预置夹具改为随包发布的五套(仅聊天 / 编程用 / 查找资料 / 项目梳理 / 写作)。
|
|
9
|
+
|
|
5
10
|
## 未发布
|
|
6
11
|
|
|
7
12
|
- 修 `kit_list`:返回值里带 `note`,但输出 schema 没有声明它,宿主按 schema 校验,于是这个工具**每次调用都直接失败**(`kit_list returned invalid output: "note" is not a declared property`)。现在 `note` 是合法字段,渲染时也会在套装名后显示备注;`selftest` 新增断言 `kit_list declares every field it returns`,防止返回值与 schema 再次漂移。
|
package/index.js
CHANGED
|
@@ -1516,7 +1516,7 @@ export function apply(ctx, config = {}) {
|
|
|
1516
1516
|
throw new Error('至少要选一个内置工具或一个插件,否则这个包等于"全部"')
|
|
1517
1517
|
}
|
|
1518
1518
|
if (oldName !== kit.name) {
|
|
1519
|
-
for (const [agent, entry] of state) {
|
|
1519
|
+
for (const [agent, entry] of [...state]) {
|
|
1520
1520
|
if (entry.selection.base === BASE_KIT && entry.selection.kit === oldName) {
|
|
1521
1521
|
entry.selection = { ...entry.selection, kit: kit.name }
|
|
1522
1522
|
entry.label = labelOf(agent, entry.selection)
|
|
@@ -1526,7 +1526,11 @@ export function apply(ctx, config = {}) {
|
|
|
1526
1526
|
// Editing the tools of the kit a conversation is running has to change that
|
|
1527
1527
|
// conversation now: a saved edit that only takes effect after the next
|
|
1528
1528
|
// switch reads as "the edit did nothing".
|
|
1529
|
-
|
|
1529
|
+
//
|
|
1530
|
+
// Iterate a snapshot: `useSelection` deletes and re-adds the agent's entry,
|
|
1531
|
+
// and a Map iterator that is handed back an entry it already passed never
|
|
1532
|
+
// finishes (that is a hang, not a slow loop).
|
|
1533
|
+
for (const [agent, entry] of [...state]) {
|
|
1530
1534
|
if (entry.selection.base === BASE_KIT && entry.selection.kit === kit.name) {
|
|
1531
1535
|
useSelection(agent, { base: BASE_KIT, kit: kit.name, extras: [...entry.selection.extras] })
|
|
1532
1536
|
}
|
|
@@ -1553,7 +1557,9 @@ export function apply(ctx, config = {}) {
|
|
|
1553
1557
|
for (const [sessionId, value] of Object.entries(book.assignments)) {
|
|
1554
1558
|
if (value === kit.name) book.assignments[sessionId] = null
|
|
1555
1559
|
}
|
|
1556
|
-
for
|
|
1560
|
+
// Snapshot for the same reason as `update`: each `useSelection` rewrites the
|
|
1561
|
+
// map entry it belongs to.
|
|
1562
|
+
for (const [agent, entry] of [...state]) {
|
|
1557
1563
|
if (entry.selection.base === BASE_KIT && entry.selection.kit === kit.name) {
|
|
1558
1564
|
useSelection(agent, emptySelection())
|
|
1559
1565
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loadout",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Loadout for DeepSeek Harness: group tools into kits, keep only the current kit's tools visible in each conversation, and manage everything from a settings panel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/scripts/client-smoke.mjs
CHANGED
|
@@ -179,11 +179,13 @@ const fixture = {
|
|
|
179
179
|
ok: true,
|
|
180
180
|
source: 'C:/fake/.dsh/loadout.json',
|
|
181
181
|
presets: [
|
|
182
|
+
{ name: 'chat', title: '仅聊天', allow: ['ask_user_question', 'web_search', 'read'] },
|
|
183
|
+
{ name: 'coding', title: '编程用', allow: ['pwsh', 'read'] },
|
|
184
|
+
{ name: 'research', title: '查找资料', allow: ['web_search', 'web_fetch'] },
|
|
185
|
+
{ name: 'plan', title: '项目梳理', allow: ['read', 'write', 'todo_write'] },
|
|
182
186
|
{ name: 'writing', title: '写作', allow: ['read', 'write', 'edit'] },
|
|
183
|
-
{ name: 'research', title: '调研', allow: ['web_search', 'web_fetch'] },
|
|
184
|
-
{ name: 'coding', title: '编程', allow: ['pwsh', 'read'] },
|
|
185
187
|
],
|
|
186
|
-
presetNames: ['
|
|
188
|
+
presetNames: ['chat', 'coding', 'research', 'plan', 'writing'],
|
|
187
189
|
kits: [
|
|
188
190
|
{ name: 'mine', title: '我的套', note: '用于视频制作', builtins: ['read'], plugins: [], allow: ['read'], description: '内置 1 个', tokens: 860, isDefault: true },
|
|
189
191
|
{ name: 'other', title: '另一套', builtins: ['write'], plugins: [], allow: ['write'], description: '内置 1 个', tokens: 420, isDefault: false },
|
|
@@ -246,7 +248,7 @@ if (menu !== undefined) {
|
|
|
246
248
|
check('我的套装 counts user kits only', allText.includes('我的套装(2)'), allText.slice(0, 400))
|
|
247
249
|
// Presets are back from 0.12: they are the quickest way to start a known kind
|
|
248
250
|
// of work, they stay read-only, and each one now says what it leaves out.
|
|
249
|
-
check('menu lists the presets again', allText.includes('预设套装(
|
|
251
|
+
check('menu lists the presets again', allText.includes('预设套装(5)') && allText.includes('写作') && allText.includes('仅聊天'), allText.slice(0, 400))
|
|
250
252
|
check('加挂 counts extras', allText.includes('加挂(1)'), allText.slice(0, 400))
|
|
251
253
|
check('footer text is the approved sentence', allText.includes(FOOTER))
|
|
252
254
|
check('the menu shows the note with the kit name', allText.includes('我的套(用于视频制作)'), allText.slice(0, 400))
|
|
@@ -320,7 +322,7 @@ try {
|
|
|
320
322
|
try {
|
|
321
323
|
const panel = expand(registration.component({ preview: { data: fixture, openSections: { presets: true, kits: true } } }))
|
|
322
324
|
const panelText = textOf(panel)
|
|
323
|
-
check('settings lists the presets again', panelText.includes('预设套装 · 只读(
|
|
325
|
+
check('settings lists the presets again', panelText.includes('预设套装 · 只读(5)') && panelText.includes('写作') && panelText.includes('项目梳理'), panelText.slice(0, 400))
|
|
324
326
|
check('settings lists my kits', panelText.includes('我的套装'), panelText.slice(0, 400))
|
|
325
327
|
check('settings picks a default kit', panelText.includes('新对话默认套装') && panelText.includes('仅内置'), panelText.slice(0, 400))
|
|
326
328
|
check('a note rides next to the kit name', panelText.includes('我的套(用于视频制作)'), panelText.slice(0, 400))
|
|
@@ -335,7 +337,7 @@ try {
|
|
|
335
337
|
const copyButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '复制切换命令')
|
|
336
338
|
const defaultButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '设为默认')
|
|
337
339
|
// 2 defaults + 3 presets + 2 user kits — every card carries both actions.
|
|
338
|
-
check('全部 and 仅内置 are cards with the two actions', copyButtons.length ===
|
|
340
|
+
check('全部 and 仅内置 are cards with the two actions', copyButtons.length === 9 && defaultButtons.length === 9, `${copyButtons.length}/${defaultButtons.length}`)
|
|
339
341
|
check('preset cards no longer offer save-as', !panelText.includes('另存为我的套装'), panelText.slice(0, 400))
|
|
340
342
|
const editButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '编辑')
|
|
341
343
|
check('settings offers an edit action for saved kits', editButtons.length === 2, `${editButtons.length} · 编辑`)
|
package/scripts/selftest.mjs
CHANGED
|
@@ -151,13 +151,50 @@ function childFor(dependencies, built, commands, routes) {
|
|
|
151
151
|
return child
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
const COLLAB = ['send_message', 'list_agents', 'interrupt_agent', 'subagent', 'subagent_fork']
|
|
155
|
+
|
|
156
|
+
// The coding preset restricted to what the fake runtime actually exposes: the
|
|
157
|
+
// collaboration five are listed by the real preset, but this fake agent only owns
|
|
158
|
+
// subagent from that group, and a tool it cannot see can never survive a mask.
|
|
159
|
+
const CODING_KEPT = ['pwsh', 'read', 'write', 'edit', 'glob', 'grep', 'todo_write', 'subagent', ...OWN_TOOLS]
|
|
160
|
+
|
|
161
|
+
// Mirrors the presets in `cordis.patch.yml` (the host hands this same shape to
|
|
162
|
+
// `apply`). The last check in this block keeps the two lists from drifting.
|
|
154
163
|
const config = {
|
|
155
164
|
defaultKit: 'writing',
|
|
156
165
|
kits: [
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
{
|
|
167
|
+
name: 'chat',
|
|
168
|
+
title: '仅聊天',
|
|
169
|
+
builtins: ['ask_user_question', 'weather', 'web_search', 'web_fetch', 'read'],
|
|
170
|
+
},
|
|
171
|
+
{ name: 'coding', title: '编程用', builtins: ['pwsh', 'read', 'write', 'edit', 'glob', 'grep', 'todo_write'] },
|
|
172
|
+
{
|
|
173
|
+
name: 'research',
|
|
174
|
+
title: '查找资料',
|
|
175
|
+
builtins: ['web_search', 'web_fetch', 'advanced_search', 'research', 'platform_search', 'read', 'write', 'glob', 'grep'],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'plan',
|
|
179
|
+
title: '项目梳理',
|
|
180
|
+
builtins: ['ask_user_question', 'web_search', 'web_fetch', 'read', 'write', 'edit', 'glob', 'grep', 'todo_write'],
|
|
181
|
+
},
|
|
182
|
+
{ name: 'writing', title: '写作', builtins: ['read', 'write', 'edit', 'glob', 'grep'] },
|
|
183
|
+
].map((preset) => ({
|
|
184
|
+
...preset,
|
|
185
|
+
// Every preset carries the collaboration five, or a subagent running it
|
|
186
|
+
// cannot answer its parent at all.
|
|
187
|
+
builtins: [...preset.builtins, 'send_message', 'list_agents', 'interrupt_agent', 'subagent', 'subagent_fork'],
|
|
188
|
+
})),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The five preset ids must exist in the shipped patch too — the test's copy above
|
|
192
|
+
// is hand-written, and a rename in one place without the other is silent drift.
|
|
193
|
+
{
|
|
194
|
+
const patch = readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
|
|
195
|
+
const missing = config.kits.filter((preset) => !new RegExp(`name:\\s*${preset.name}\\b`).test(patch))
|
|
196
|
+
check('the shipped patch carries every preset the tests use',
|
|
197
|
+
missing.length === 0, missing.map((preset) => preset.name).join(','))
|
|
161
198
|
}
|
|
162
199
|
|
|
163
200
|
const ctx = makeCtx()
|
|
@@ -345,7 +382,7 @@ let listed = await run('kit_list', {}, agent)
|
|
|
345
382
|
check('kit_list active title', listed.active === '写作', listed.active)
|
|
346
383
|
check('kit_list base is kit', listed.base === 'kit', listed.base)
|
|
347
384
|
check('kit_list prefers the shipped config', listed.source === 'bundle 配置', listed.source)
|
|
348
|
-
check('kit_list
|
|
385
|
+
check('kit_list lists the five shipped presets', listed.presets.length === 5, String(listed.presets.length))
|
|
349
386
|
check('kit_list has no user kit yet', listed.kits.length === 0, String(listed.kits.length))
|
|
350
387
|
// The host validates execute() output against the declared schema, so a field
|
|
351
388
|
// that is returned but not declared fails the whole call at runtime
|
|
@@ -360,12 +397,12 @@ check('kit_list has no user kit yet', listed.kits.length === 0, String(listed.ki
|
|
|
360
397
|
}
|
|
361
398
|
|
|
362
399
|
// ---- kit_apply by title ------------------------------------------------------
|
|
363
|
-
const appliedKit = await run('kit_apply', { kit: '
|
|
400
|
+
const appliedKit = await run('kit_apply', { kit: '编程用' }, agent)
|
|
364
401
|
check('kit_apply by title', appliedKit.kit === 'coding', appliedKit.kit)
|
|
365
402
|
check('previous restriction released', agent.filters[0].active === false)
|
|
366
403
|
check(
|
|
367
404
|
'preset keeps exactly its built-ins plus own tools',
|
|
368
|
-
sameSet(keptOf(agent),
|
|
405
|
+
sameSet(keptOf(agent), CODING_KEPT),
|
|
369
406
|
keptOf(agent).join(','),
|
|
370
407
|
)
|
|
371
408
|
|
|
@@ -380,7 +417,7 @@ check('unknown kit throws', unknownError.includes('未知套装'), unknownError)
|
|
|
380
417
|
// ---- GET /state exposes the whole model -------------------------------------
|
|
381
418
|
let state = await getState()
|
|
382
419
|
check('state route 200', state.status === 200, String(state.status))
|
|
383
|
-
check('state lists presets with names', JSON.stringify(state.body.presetNames) === JSON.stringify(['
|
|
420
|
+
check('state lists presets with names', JSON.stringify(state.body.presetNames) === JSON.stringify(['chat', 'coding', 'research', 'plan', 'writing']), JSON.stringify(state.body.presetNames))
|
|
384
421
|
check('state lists preset allow', Array.isArray(state.body.presets) && state.body.presets[0].allow.includes('read'), JSON.stringify(state.body.presets?.[0]))
|
|
385
422
|
check('state splits the catalog', Array.isArray(state.body.catalog.builtins) && Array.isArray(state.body.catalog.plugins), JSON.stringify(state.body.catalog))
|
|
386
423
|
check(
|
|
@@ -433,7 +470,7 @@ check('presets carry a token estimate', Number.isFinite(answer.body.presets[0].t
|
|
|
433
470
|
answer = await post({ action: 'applyBase', sessionId: SESSION_ID, base: 'kit', kit: 'coding' })
|
|
434
471
|
const kitKept = keptOf(agent)
|
|
435
472
|
check('applyBase kit 200', answer.status === 200, JSON.stringify(answer.body))
|
|
436
|
-
check('kit keeps only its built-ins', sameSet(kitKept,
|
|
473
|
+
check('kit keeps only its built-ins', sameSet(kitKept, CODING_KEPT), kitKept.join(','))
|
|
437
474
|
check('kit masks plugin tools', !kitKept.includes('demo_search'), kitKept.join(','))
|
|
438
475
|
|
|
439
476
|
let badBase = await post({ action: 'applyBase', sessionId: SESSION_ID, base: 'kit', kit: 'ghost' })
|
|
@@ -678,7 +715,7 @@ check('GET answers for the Session it names', firstView.body.selection.kit === '
|
|
|
678
715
|
check('GET answers for a different Session', secondView.body.selection.kit === 'research', JSON.stringify(secondView.body.selection))
|
|
679
716
|
check(
|
|
680
717
|
'two Sessions do not leak into each other',
|
|
681
|
-
firstView.body.label === '
|
|
718
|
+
firstView.body.label === '编程用' && secondView.body.label === '查找资料',
|
|
682
719
|
JSON.stringify([firstView.body.label, secondView.body.label]),
|
|
683
720
|
)
|
|
684
721
|
|
|
@@ -785,6 +822,84 @@ try {
|
|
|
785
822
|
}
|
|
786
823
|
check('a reserved name is refused', /全部/u.test(reservedError ?? ''), reservedError)
|
|
787
824
|
|
|
825
|
+
// T18: the same name with different case or surrounding space is the same kit.
|
|
826
|
+
let caseDupError
|
|
827
|
+
await ctxV2.registered.get('kit_create').execute(
|
|
828
|
+
{ name: 'qa', title: '问答', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
829
|
+
try {
|
|
830
|
+
await ctxV2.registered.get('kit_create').execute(
|
|
831
|
+
{ name: ' QA ', title: '重复', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
832
|
+
} catch (error) {
|
|
833
|
+
caseDupError = String(error?.message ?? error)
|
|
834
|
+
}
|
|
835
|
+
check('a name that differs only by case or space is refused',
|
|
836
|
+
/已存在/u.test(caseDupError ?? ''), caseDupError)
|
|
837
|
+
|
|
838
|
+
// T24: a user kit may not shadow a shipped preset.
|
|
839
|
+
let presetClashError
|
|
840
|
+
try {
|
|
841
|
+
await ctxV2.registered.get('kit_create').execute(
|
|
842
|
+
{ name: 'chat', title: '冒充预置', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
843
|
+
} catch (error) {
|
|
844
|
+
presetClashError = String(error?.message ?? error)
|
|
845
|
+
}
|
|
846
|
+
check('a preset name cannot be taken by a user kit',
|
|
847
|
+
/预置/u.test(presetClashError ?? ''), presetClashError)
|
|
848
|
+
|
|
849
|
+
// T03/T05: the default changes again; conversations that already answered keep theirs.
|
|
850
|
+
const agentC = makeAgent(undefined, { id: 'session-c' })
|
|
851
|
+
await ctxV2.registered.get('kit_set_default').execute({ name: 'chat' }, exec(agentB))
|
|
852
|
+
fireCreated(agentC)
|
|
853
|
+
check('changing the default twice keeps earlier conversations',
|
|
854
|
+
storeOf().assignments['session-a'] === null && storeOf().assignments['session-c'] === 'chat',
|
|
855
|
+
JSON.stringify(storeOf().assignments))
|
|
856
|
+
|
|
857
|
+
// T08: deleting a kit reports and releases every conversation that held it.
|
|
858
|
+
await ctxV2.registered.get('kit_create').execute(
|
|
859
|
+
{ name: 'shared', title: '共享', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
860
|
+
for (const id of ['session-a', 'session-b', 'session-c']) {
|
|
861
|
+
await ctxV2.registered.get('kit_assign').execute({ sessionId: id, kit: 'shared' }, exec(agentB))
|
|
862
|
+
}
|
|
863
|
+
const sharedDelete = await ctxV2.registered.get('kit_delete').execute({ name: 'shared' }, exec(agentB))
|
|
864
|
+
check('deleting a kit reports every conversation it held',
|
|
865
|
+
sharedDelete.affectedSessions === 3
|
|
866
|
+
&& ['session-a', 'session-b', 'session-c'].every((id) => storeOf().assignments[id] === null),
|
|
867
|
+
JSON.stringify({ sharedDelete, assignments: storeOf().assignments }))
|
|
868
|
+
|
|
869
|
+
// T09: deleting the default kit also clears the default.
|
|
870
|
+
await ctxV2.registered.get('kit_create').execute(
|
|
871
|
+
{ name: 'shared-two', title: '共享二', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
872
|
+
await ctxV2.registered.get('kit_set_default').execute({ name: 'shared-two' }, exec(agentB))
|
|
873
|
+
await ctxV2.registered.get('kit_assign').execute({ sessionId: 'session-a', kit: 'shared-two' }, exec(agentB))
|
|
874
|
+
const defaultDelete = await ctxV2.registered.get('kit_delete').execute({ name: 'shared-two' }, exec(agentB))
|
|
875
|
+
check('deleting the default kit clears the default',
|
|
876
|
+
defaultDelete.affectedSessions === 1
|
|
877
|
+
&& (storeOf().defaultKit === null || storeOf().defaultKit === ''),
|
|
878
|
+
JSON.stringify({ defaultDelete, defaultKit: storeOf().defaultKit }))
|
|
879
|
+
|
|
880
|
+
// T10: renaming changes the display name only; bindings point at the stable id.
|
|
881
|
+
await ctxV2.registered.get('kit_create').execute(
|
|
882
|
+
{ name: 'stable', title: '稳定', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
883
|
+
await ctxV2.registered.get('kit_assign').execute({ sessionId: 'session-b', kit: 'stable' }, exec(agentB))
|
|
884
|
+
await ctxV2.registered.get('kit_rename').execute({ name: 'stable', title: '稳定改名' }, exec(agentB))
|
|
885
|
+
check('renaming a bound kit keeps the binding',
|
|
886
|
+
storeOf().assignments['session-b'] === 'stable'
|
|
887
|
+
&& storeOf().kits.some((kit) => kit.name === 'stable' && kit.title === '稳定改名'),
|
|
888
|
+
JSON.stringify(storeOf().kits.map((kit) => [kit.name, kit.title])))
|
|
889
|
+
|
|
890
|
+
// T15: every write is a complete file — the temporary one never survives.
|
|
891
|
+
check('the store write leaves no temporary file',
|
|
892
|
+
!existsSync(join(v2Home, '.dsh', 'loadout.json.tmp')), 'loadout.json.tmp is still there')
|
|
893
|
+
|
|
894
|
+
// T25: a shipped preset can be the new-conversation default too.
|
|
895
|
+
await ctxV2.registered.get('kit_set_default').execute({ name: 'plan' }, exec(agentB))
|
|
896
|
+
const agentD = makeAgent(undefined, { id: 'session-d' })
|
|
897
|
+
fireCreated(agentD)
|
|
898
|
+
check('a preset can be the default',
|
|
899
|
+
storeOf().assignments['session-d'] === 'plan'
|
|
900
|
+
&& keptOf(agentD).includes('read') && !keptOf(agentD).includes('pwsh'),
|
|
901
|
+
JSON.stringify({ binding: storeOf().assignments['session-d'], kept: keptOf(agentD) }))
|
|
902
|
+
|
|
788
903
|
process.env.USERPROFILE = savedHomeV2
|
|
789
904
|
process.env.HOME = savedHomeV2b
|
|
790
905
|
rmSync(v2Home, { recursive: true, force: true })
|