dsh-loadout 0.11.2 → 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 +24 -0
- package/README.en.md +19 -12
- package/README.md +38 -12
- package/client.js +24 -10
- package/cordis.patch.yml +62 -13
- package/index.js +482 -70
- package/package.json +1 -1
- package/scripts/client-smoke.mjs +13 -9
- package/scripts/selftest.mjs +274 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loadout",
|
|
3
|
-
"version": "0.
|
|
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 },
|
|
@@ -244,9 +246,9 @@ if (menu !== undefined) {
|
|
|
244
246
|
check(`menu shows «${label}»`, allText.includes(label), allText.slice(0, 400))
|
|
245
247
|
}
|
|
246
248
|
check('我的套装 counts user kits only', allText.includes('我的套装(2)'), allText.slice(0, 400))
|
|
247
|
-
// Presets
|
|
248
|
-
//
|
|
249
|
-
check('menu
|
|
249
|
+
// Presets are back from 0.12: they are the quickest way to start a known kind
|
|
250
|
+
// of work, they stay read-only, and each one now says what it leaves out.
|
|
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
|
|
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))
|
|
@@ -330,10 +332,12 @@ try {
|
|
|
330
332
|
check('settings says which conversation the ledger describes', panelText.includes('最近活跃对话保留') && panelText.includes('设置页不绑定具体会话'), panelText.slice(0, 400))
|
|
331
333
|
// 全部 / 仅内置 sit in the same list as the kits, so they are rendered as cards
|
|
332
334
|
// with the same two actions; presets are read-only and carry no save-as button.
|
|
333
|
-
|
|
335
|
+
// The default section says out loud that it cannot reach an open conversation.
|
|
336
|
+
check('settings says the default only affects new conversations', panelText.includes('只影响之后新建的对话'), panelText.slice(0, 400))
|
|
334
337
|
const copyButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '复制切换命令')
|
|
335
338
|
const defaultButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '设为默认')
|
|
336
|
-
|
|
339
|
+
// 2 defaults + 3 presets + 2 user kits — every card carries both actions.
|
|
340
|
+
check('全部 and 仅内置 are cards with the two actions', copyButtons.length === 9 && defaultButtons.length === 9, `${copyButtons.length}/${defaultButtons.length}`)
|
|
337
341
|
check('preset cards no longer offer save-as', !panelText.includes('另存为我的套装'), panelText.slice(0, 400))
|
|
338
342
|
const editButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '编辑')
|
|
339
343
|
check('settings offers an edit action for saved kits', editButtons.length === 2, `${editButtons.length} · 编辑`)
|
package/scripts/selftest.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* & "…\DeepSeek Harness.exe" dsh-loadout\scripts\selftest.mjs
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { tmpdir } from 'node:os'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
15
|
|
|
@@ -32,7 +32,7 @@ const check = (label, ok, detail) => {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const SESSION_ID = 'session-1'
|
|
35
|
-
const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete']
|
|
35
|
+
const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete', 'kit_assign', 'kit_rename']
|
|
36
36
|
const SCOPED = ['read', 'write', 'edit', 'glob', 'grep', 'pwsh', 'web_search', 'web_fetch', 'todo_write']
|
|
37
37
|
/** Registered by each agent into its own layer: visible, but never restrictable. */
|
|
38
38
|
const SCOPE_OWNED = ['subagent']
|
|
@@ -61,6 +61,14 @@ const definitionOf = (toolName) => ({
|
|
|
61
61
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Session headers and agents are shared by every fake host in this file: the
|
|
66
|
+
* plugin keeps one registry per process, and a second `apply()` (the legacy and
|
|
67
|
+
* v2 blocks) must see the conversations the first one registered.
|
|
68
|
+
*/
|
|
69
|
+
const fakeSessions = new Map()
|
|
70
|
+
const fakeAgents = new Map()
|
|
71
|
+
|
|
64
72
|
/** One fake host. `layers` is what the real tool runtime exposes. */
|
|
65
73
|
function makeCtx() {
|
|
66
74
|
const tools = new Map()
|
|
@@ -92,10 +100,15 @@ function makeCtx() {
|
|
|
92
100
|
optional(dependencies, install) { install(childFor(dependencies, built, commands, routes, sessionAgentOf)) },
|
|
93
101
|
inject(dependencies, install) { install(childFor(dependencies, built, commands, routes, sessionAgentOf)) },
|
|
94
102
|
}
|
|
95
|
-
|
|
103
|
+
// `sessions` is the host's Session registry: `get(id)` hands back a Session
|
|
104
|
+
// whose `header` carries `createdAt` / `parentSession` / `origin`. The agents
|
|
105
|
+
// themselves live in their own map — the plugin only reads headers from this
|
|
106
|
+
// one, and `sessionController` is what resolves a Session id to an agent.
|
|
107
|
+
const sessions = fakeSessions
|
|
108
|
+
const agents = fakeAgents
|
|
96
109
|
built.sessionController = {
|
|
97
110
|
async resolveAgent(sessionId) {
|
|
98
|
-
if (
|
|
111
|
+
if (agents.has(sessionId)) return { agent: agents.get(sessionId) }
|
|
99
112
|
if (sessionId === SESSION_ID) return { agent: sessionAgentOf.agent }
|
|
100
113
|
return { error: { message: `找不到会话 "${sessionId}"` } }
|
|
101
114
|
},
|
|
@@ -104,7 +117,9 @@ function makeCtx() {
|
|
|
104
117
|
built.handlers = handlers
|
|
105
118
|
built.commands = commands
|
|
106
119
|
built.routes = routes
|
|
107
|
-
built.sessions = sessions
|
|
120
|
+
built.sessions = { get: (sessionId) => sessions.get(sessionId) }
|
|
121
|
+
built.sessionTable = sessions
|
|
122
|
+
built.agentTable = agents
|
|
108
123
|
return built
|
|
109
124
|
}
|
|
110
125
|
|
|
@@ -130,16 +145,56 @@ function childFor(dependencies, built, commands, routes) {
|
|
|
130
145
|
}
|
|
131
146
|
}
|
|
132
147
|
if (dependencies.includes('sessionController')) child.sessionController = built.sessionController
|
|
148
|
+
// The plugin injects `sessions` optionally; a fake host without it must still
|
|
149
|
+
// work (that is the 0.x path), so this mirrors the real `optional` contract.
|
|
150
|
+
if (dependencies.includes('sessions')) child.sessions = built.sessions
|
|
133
151
|
return child
|
|
134
152
|
}
|
|
135
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.
|
|
136
163
|
const config = {
|
|
137
164
|
defaultKit: 'writing',
|
|
138
165
|
kits: [
|
|
139
|
-
{
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(','))
|
|
143
198
|
}
|
|
144
199
|
|
|
145
200
|
const ctx = makeCtx()
|
|
@@ -178,8 +233,13 @@ check('registered state route', ctx.routes.has('/dsh-loadout/state'))
|
|
|
178
233
|
* `layers.effect` is the append `restrict()` performs internally, which is how
|
|
179
234
|
* the plugin masks those same tools without ever writing an allow list.
|
|
180
235
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
236
|
+
let agentSeq = 0
|
|
237
|
+
|
|
238
|
+
function makeAgent(tools = [...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOOLS], options = {}) {
|
|
239
|
+
// Every agent carries a Session id, because that id is the key a conversation's
|
|
240
|
+
// binding is stored under (`assignments[sessionId]`). An id-less fake made the
|
|
241
|
+
// whole binding layer look like a no-op.
|
|
242
|
+
const agent = { id: options.id ?? `session-agent-${++agentSeq}`, tools, filters: [], restrictable: new Set(GLOBAL) }
|
|
183
243
|
const append = (filter) => {
|
|
184
244
|
const entry = { filter, active: true }
|
|
185
245
|
agent.filters.push(entry)
|
|
@@ -230,6 +290,15 @@ function makeAgent(tools = [...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOO
|
|
|
230
290
|
},
|
|
231
291
|
},
|
|
232
292
|
}
|
|
293
|
+
// Publish the Session header and the agent itself, so `ensureBinding()` can
|
|
294
|
+
// read `createdAt` / `parentSession` / `origin` exactly like the host does.
|
|
295
|
+
const header = {
|
|
296
|
+
createdAt: options.createdAt ?? Date.now(),
|
|
297
|
+
...(options.parentSession === undefined ? {} : { parentSession: options.parentSession }),
|
|
298
|
+
...(options.origin === undefined ? {} : { origin: options.origin }),
|
|
299
|
+
}
|
|
300
|
+
fakeSessions.set(agent.id, { id: agent.id, header })
|
|
301
|
+
fakeAgents.set(agent.id, agent)
|
|
233
302
|
return agent
|
|
234
303
|
}
|
|
235
304
|
|
|
@@ -313,16 +382,27 @@ let listed = await run('kit_list', {}, agent)
|
|
|
313
382
|
check('kit_list active title', listed.active === '写作', listed.active)
|
|
314
383
|
check('kit_list base is kit', listed.base === 'kit', listed.base)
|
|
315
384
|
check('kit_list prefers the shipped config', listed.source === 'bundle 配置', listed.source)
|
|
316
|
-
check('kit_list
|
|
385
|
+
check('kit_list lists the five shipped presets', listed.presets.length === 5, String(listed.presets.length))
|
|
317
386
|
check('kit_list has no user kit yet', listed.kits.length === 0, String(listed.kits.length))
|
|
387
|
+
// The host validates execute() output against the declared schema, so a field
|
|
388
|
+
// that is returned but not declared fails the whole call at runtime
|
|
389
|
+
// (`kit_list returned invalid output: "note" is not a declared property`).
|
|
390
|
+
// Keep the two sides in sync.
|
|
391
|
+
{
|
|
392
|
+
const schema = registered.get('kit_list').output.schema
|
|
393
|
+
const kitItem = schema.properties.kits.items.properties
|
|
394
|
+
const presetItem = schema.properties.presets.items.properties
|
|
395
|
+
check('kit_list declares every field it returns', 'note' in kitItem && 'note' in presetItem,
|
|
396
|
+
`kits=${Object.keys(kitItem).join(',')} presets=${Object.keys(presetItem).join(',')}`)
|
|
397
|
+
}
|
|
318
398
|
|
|
319
399
|
// ---- kit_apply by title ------------------------------------------------------
|
|
320
|
-
const appliedKit = await run('kit_apply', { kit: '
|
|
400
|
+
const appliedKit = await run('kit_apply', { kit: '编程用' }, agent)
|
|
321
401
|
check('kit_apply by title', appliedKit.kit === 'coding', appliedKit.kit)
|
|
322
402
|
check('previous restriction released', agent.filters[0].active === false)
|
|
323
403
|
check(
|
|
324
404
|
'preset keeps exactly its built-ins plus own tools',
|
|
325
|
-
sameSet(keptOf(agent),
|
|
405
|
+
sameSet(keptOf(agent), CODING_KEPT),
|
|
326
406
|
keptOf(agent).join(','),
|
|
327
407
|
)
|
|
328
408
|
|
|
@@ -337,7 +417,7 @@ check('unknown kit throws', unknownError.includes('未知套装'), unknownError)
|
|
|
337
417
|
// ---- GET /state exposes the whole model -------------------------------------
|
|
338
418
|
let state = await getState()
|
|
339
419
|
check('state route 200', state.status === 200, String(state.status))
|
|
340
|
-
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))
|
|
341
421
|
check('state lists preset allow', Array.isArray(state.body.presets) && state.body.presets[0].allow.includes('read'), JSON.stringify(state.body.presets?.[0]))
|
|
342
422
|
check('state splits the catalog', Array.isArray(state.body.catalog.builtins) && Array.isArray(state.body.catalog.plugins), JSON.stringify(state.body.catalog))
|
|
343
423
|
check(
|
|
@@ -390,7 +470,7 @@ check('presets carry a token estimate', Number.isFinite(answer.body.presets[0].t
|
|
|
390
470
|
answer = await post({ action: 'applyBase', sessionId: SESSION_ID, base: 'kit', kit: 'coding' })
|
|
391
471
|
const kitKept = keptOf(agent)
|
|
392
472
|
check('applyBase kit 200', answer.status === 200, JSON.stringify(answer.body))
|
|
393
|
-
check('kit keeps only its built-ins', sameSet(kitKept,
|
|
473
|
+
check('kit keeps only its built-ins', sameSet(kitKept, CODING_KEPT), kitKept.join(','))
|
|
394
474
|
check('kit masks plugin tools', !kitKept.includes('demo_search'), kitKept.join(','))
|
|
395
475
|
|
|
396
476
|
let badBase = await post({ action: 'applyBase', sessionId: SESSION_ID, base: 'kit', kit: 'ghost' })
|
|
@@ -540,7 +620,7 @@ check('a note survives a restart', afterRestart.kits.find((kit) => kit.name ===
|
|
|
540
620
|
// ---- store schema ----------------------------------------------------------
|
|
541
621
|
// 0.11.0 stamps the file so a later release can migrate instead of guessing.
|
|
542
622
|
const stamped = JSON.parse(readFileSync(storePath, 'utf8')).schema
|
|
543
|
-
check('the store file records its schema version', stamped ===
|
|
623
|
+
check('the store file records its schema version', stamped === 2, String(stamped))
|
|
544
624
|
|
|
545
625
|
// A store written by 0.10.x has no `schema` field at all; it still has to load,
|
|
546
626
|
// because that is the exact file everyone upgrading from 0.10.x has on disk.
|
|
@@ -625,8 +705,7 @@ check('non-local Host refused', foreignResponse.status === 403, String(foreignRe
|
|
|
625
705
|
// ---- GET /state is per-Session: two agents never leak into each other -------
|
|
626
706
|
// The header chip asks for one conversation; answering from whichever agent was
|
|
627
707
|
// touched last is the bug this covers.
|
|
628
|
-
const secondAgent = makeAgent()
|
|
629
|
-
ctx.sessions.set('session-2', secondAgent)
|
|
708
|
+
const secondAgent = makeAgent([...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOOLS], { id: 'session-2' })
|
|
630
709
|
await post({ action: 'applyBase', sessionId: SESSION_ID, base: 'kit', kit: 'coding' })
|
|
631
710
|
await post({ action: 'applyBase', sessionId: 'session-2', base: 'kit', kit: 'research' })
|
|
632
711
|
|
|
@@ -636,7 +715,7 @@ check('GET answers for the Session it names', firstView.body.selection.kit === '
|
|
|
636
715
|
check('GET answers for a different Session', secondView.body.selection.kit === 'research', JSON.stringify(secondView.body.selection))
|
|
637
716
|
check(
|
|
638
717
|
'two Sessions do not leak into each other',
|
|
639
|
-
firstView.body.label === '
|
|
718
|
+
firstView.body.label === '编程用' && secondView.body.label === '查找资料',
|
|
640
719
|
JSON.stringify([firstView.body.label, secondView.body.label]),
|
|
641
720
|
)
|
|
642
721
|
|
|
@@ -654,6 +733,177 @@ check(
|
|
|
654
733
|
JSON.stringify(ghostView.body.selection),
|
|
655
734
|
)
|
|
656
735
|
|
|
736
|
+
// ---- v2: one persisted binding per conversation ----------------------------
|
|
737
|
+
// The plan's core promise: a conversation carries its own choice, so changing
|
|
738
|
+
// the default later never reaches back into it, and an explicit switch survives
|
|
739
|
+
// a restart. Everything here reads the store file, not the in-memory state.
|
|
740
|
+
const v2Home = join(tmpdir(), `loadout-v2-${process.pid}`)
|
|
741
|
+
rmSync(v2Home, { recursive: true, force: true })
|
|
742
|
+
mkdirSync(join(v2Home, '.dsh', 'sessions', 'ws', 'session-old'), { recursive: true })
|
|
743
|
+
const savedHomeV2 = process.env.USERPROFILE
|
|
744
|
+
const savedHomeV2b = process.env.HOME
|
|
745
|
+
process.env.USERPROFILE = v2Home
|
|
746
|
+
process.env.HOME = v2Home
|
|
747
|
+
writeFileSync(join(v2Home, '.dsh', 'loadout.json'), JSON.stringify({
|
|
748
|
+
schema: 1,
|
|
749
|
+
kits: [{ name: 'mine', title: '我的', builtins: ['read', 'write'], plugins: [] }],
|
|
750
|
+
defaultKit: '',
|
|
751
|
+
}), 'utf8')
|
|
752
|
+
const v2 = await import('../index.js?v2=1')
|
|
753
|
+
const ctxV2 = makeCtx()
|
|
754
|
+
v2.apply(ctxV2, { defaultKit: '', kits: config.kits })
|
|
755
|
+
const fireCreated = (agent) => {
|
|
756
|
+
for (const handler of ctxV2.handlers.get('agent/created') ?? []) handler(agent)
|
|
757
|
+
}
|
|
758
|
+
const storeOf = () => JSON.parse(readFileSync(join(v2Home, '.dsh', 'loadout.json'), 'utf8'))
|
|
759
|
+
const backups = () => readdirSync(join(v2Home, '.dsh')).filter((entry) => entry.startsWith('loadout.json.v1.bak-'))
|
|
760
|
+
|
|
761
|
+
check('migration stamps schema 2', storeOf().schema === 2, String(storeOf().schema))
|
|
762
|
+
check('migration freezes existing conversations on 全部', storeOf().assignments['session-old'] === null,
|
|
763
|
+
JSON.stringify(storeOf().assignments))
|
|
764
|
+
check('migration keeps the schema-1 file aside', backups().length === 1, backups().join(','))
|
|
765
|
+
|
|
766
|
+
const agentA = makeAgent(undefined, { id: 'session-a' })
|
|
767
|
+
fireCreated(agentA)
|
|
768
|
+
check('a new conversation records 全部', storeOf().assignments['session-a'] === null, JSON.stringify(storeOf().assignments))
|
|
769
|
+
check('a new conversation starts unmasked', activeFilters(agentA).length === 0, String(activeFilters(agentA).length))
|
|
770
|
+
|
|
771
|
+
await ctxV2.registered.get('kit_set_default').execute({ name: 'coding' }, exec(agentA))
|
|
772
|
+
check('changing the default leaves an existing conversation alone',
|
|
773
|
+
activeFilters(agentA).length === 0 && keptOf(agentA).includes('web_search'),
|
|
774
|
+
JSON.stringify({ filters: activeFilters(agentA).length, kept: keptOf(agentA) }))
|
|
775
|
+
|
|
776
|
+
const agentB = makeAgent(undefined, { id: 'session-b' })
|
|
777
|
+
fireCreated(agentB)
|
|
778
|
+
check('a later conversation starts on the saved default',
|
|
779
|
+
!keptOf(agentB).includes('web_search') && keptOf(agentB).includes('read'), keptOf(agentB).join(','))
|
|
780
|
+
check('a later conversation records the default', storeOf().assignments['session-b'] === 'coding',
|
|
781
|
+
JSON.stringify(storeOf().assignments))
|
|
782
|
+
|
|
783
|
+
const agentFork = makeAgent(undefined, { id: 'session-fork', parentSession: 'session-b' })
|
|
784
|
+
fireCreated(agentFork)
|
|
785
|
+
check('a fork inherits its parent binding', storeOf().assignments['session-fork'] === 'coding',
|
|
786
|
+
JSON.stringify(storeOf().assignments))
|
|
787
|
+
|
|
788
|
+
const agentChild = makeAgent(undefined, { id: 'sub-1', origin: 'subagent' })
|
|
789
|
+
fireCreated(agentChild)
|
|
790
|
+
check('a subagent gets no binding', storeOf().assignments['sub-1'] === undefined,
|
|
791
|
+
JSON.stringify(storeOf().assignments))
|
|
792
|
+
|
|
793
|
+
const assigned = await ctxV2.registered.get('kit_assign').execute({ sessionId: 'session-b', kit: 'mine' }, exec(agentB))
|
|
794
|
+
check('kit_assign pins the conversation', assigned.ok !== false && storeOf().assignments['session-b'] === 'mine',
|
|
795
|
+
JSON.stringify({ assigned, assignments: storeOf().assignments }))
|
|
796
|
+
|
|
797
|
+
const dryDelete = await ctxV2.registered.get('kit_delete').execute({ name: 'mine', dryRun: true }, exec(agentB))
|
|
798
|
+
check('kit_delete --dryRun counts without deleting',
|
|
799
|
+
dryDelete.affectedSessions === 1 && storeOf().kits.some((kit) => kit.name === 'mine'),
|
|
800
|
+
JSON.stringify(dryDelete))
|
|
801
|
+
|
|
802
|
+
const deleted = await ctxV2.registered.get('kit_delete').execute({ name: 'mine' }, exec(agentB))
|
|
803
|
+
check('kit_delete clears the bindings it owned',
|
|
804
|
+
deleted.deleted === true && storeOf().assignments['session-b'] === null,
|
|
805
|
+
JSON.stringify({ deleted, assignments: storeOf().assignments }))
|
|
806
|
+
|
|
807
|
+
const createdV2 = await ctxV2.registered.get('kit_create').execute(
|
|
808
|
+
{ name: 'k-one', title: '一号', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
809
|
+
const renamed = await ctxV2.registered.get('kit_rename').execute({ name: 'k-one', title: '改过的一号' }, exec(agentB))
|
|
810
|
+
const listedV2 = await ctxV2.registered.get('kit_list').execute({}, exec(agentB))
|
|
811
|
+
check('kit_rename changes the title and keeps the id',
|
|
812
|
+
renamed.ok !== false && listedV2.kits.some((kit) => kit.name === 'k-one' && kit.title === '改过的一号'),
|
|
813
|
+
JSON.stringify({ createdV2, renamed, kits: listedV2.kits.map((kit) => [kit.name, kit.title]) }))
|
|
814
|
+
check('kit_list reports the binding', listedV2.binding === null || listedV2.binding === '',
|
|
815
|
+
JSON.stringify(listedV2.binding))
|
|
816
|
+
|
|
817
|
+
let reservedError
|
|
818
|
+
try {
|
|
819
|
+
await ctxV2.registered.get('kit_create').execute({ name: '全部', title: '占位', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
820
|
+
} catch (error) {
|
|
821
|
+
reservedError = String(error?.message ?? error)
|
|
822
|
+
}
|
|
823
|
+
check('a reserved name is refused', /全部/u.test(reservedError ?? ''), reservedError)
|
|
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
|
+
|
|
903
|
+
process.env.USERPROFILE = savedHomeV2
|
|
904
|
+
process.env.HOME = savedHomeV2b
|
|
905
|
+
rmSync(v2Home, { recursive: true, force: true })
|
|
906
|
+
|
|
657
907
|
// ---- legacy store: plugin-kits.json is read once ---------------------------
|
|
658
908
|
const legacyHome = join(tmpdir(), `loadout-legacy-${process.pid}`)
|
|
659
909
|
rmSync(legacyHome, { recursive: true, force: true })
|
|
@@ -670,7 +920,10 @@ const legacyModule = await import('../index.js?legacy=1')
|
|
|
670
920
|
const ctx3 = makeCtx()
|
|
671
921
|
legacyModule.apply(ctx3, config)
|
|
672
922
|
const legacyListed = await ctx3.registered.get('kit_list').execute({}, exec(makeAgent()))
|
|
673
|
-
|
|
923
|
+
// Migration copies the legacy kits into `loadout.json` and saves immediately, so
|
|
924
|
+
// the file the user's kits live in is the new one from the first boot. What must
|
|
925
|
+
// survive is the data (kits + default), not the old path string.
|
|
926
|
+
check('legacy store is migrated', legacyListed.source === join(legacyHome, '.dsh', 'loadout.json'), legacyListed.source)
|
|
674
927
|
check('legacy kit survives', legacyListed.kits.some((kit) => kit.name === 'old'), JSON.stringify(legacyListed.kits.map((kit) => kit.name)))
|
|
675
928
|
check('legacy default survives', legacyListed.default === 'old', legacyListed.default)
|
|
676
929
|
await ctx3.registered.get('kit_set_default').execute({ name: 'coding' }, exec(makeAgent()))
|