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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loadout",
3
- "version": "0.11.2",
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",
@@ -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: ['writing', 'research', 'coding'],
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 ship in the bundle but are held back by SHOW_PRESETS: a shipped
248
- // preset that hides tools reads like a bug, so neither surface lists them.
249
- check('menu hides presets for now', !allText.includes('预设套装') && !allText.includes('写作'), allText.slice(0, 400))
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 hides presets for now', !panelText.includes('预设套装') && !panelText.includes('写作'), panelText.slice(0, 400))
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
- check('settings no longer offers per-conversation switching', !panelText.includes('只影响之后新建的对话'), panelText.slice(0, 400))
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
- check('全部 and 仅内置 are cards with the two actions', copyButtons.length === 4 && defaultButtons.length === 4, `${copyButtons.length}/${defaultButtons.length}`)
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} · 编辑`)
@@ -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
- const sessions = new Map()
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 (sessions.has(sessionId)) return { agent: sessions.get(sessionId) }
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
- { name: 'writing', title: '写作', builtins: ['read', 'write', 'edit'] },
140
- { name: 'research', title: '调研', builtins: ['web_search', 'web_fetch'] },
141
- { name: 'coding', title: '编程', builtins: ['pwsh', 'read'] },
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
- function makeAgent(tools = [...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOOLS]) {
182
- const agent = { tools, filters: [], restrictable: new Set(GLOBAL) }
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 has three presets', listed.presets.length === 3, String(listed.presets.length))
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: '编程' }, agent)
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), ['pwsh', 'read', ...OWN_TOOLS]),
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(['writing', 'research', 'coding']), JSON.stringify(state.body.presetNames))
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, ['pwsh', 'read', ...OWN_TOOLS]), kitKept.join(','))
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 === 1, String(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 === '编程' && secondView.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
- check('legacy store is read', legacyListed.source === join(legacyHome, '.dsh', 'plugin-kits.json'), legacyListed.source)
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()))