dsh-loadout 0.12.4 → 0.13.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.
@@ -45,7 +45,7 @@ const React = {
45
45
 
46
46
  const registrations = []
47
47
  let injections = 0
48
- const SLOTS = ['settings.section', 'conversation.session.header.actions']
48
+ const SLOTS = ['settings.section', 'conversation.session.header.actions', 'conversation.input.right']
49
49
 
50
50
  const slots = {
51
51
  inject(name, callback) {
@@ -83,7 +83,9 @@ globalThis.window = {
83
83
  }
84
84
 
85
85
  // fetch must not run during load; it only runs inside effects, which the fake React never invokes.
86
- globalThis.fetch = () => Promise.reject(new Error('unexpected network call during load'))
86
+ // The indirection lets one test swap in a recorder and click a row for real.
87
+ let fetchImpl = () => Promise.reject(new Error('unexpected network call during load'))
88
+ globalThis.fetch = (...args) => fetchImpl(...args)
87
89
  globalThis.AbortController = class { abort() {} signal = {} }
88
90
 
89
91
  // The client bundle is plain script text (no exports); evaluate it with `window` in scope.
@@ -95,13 +97,21 @@ check('declares inject', Array.isArray(loaded?.inject) && loaded.inject.includes
95
97
 
96
98
  loaded?.apply(ctx)
97
99
 
98
- check('slots.inject called twice', injections === 2, String(injections))
99
- check('two surfaces registered', registrations.length === 2, String(registrations.length))
100
+ check('slots.inject called once per surface', injections === 3, String(injections))
101
+ check('three surfaces registered', registrations.length === 3, String(registrations.length))
100
102
 
101
103
  const registration = registrations.find((entry) => entry.definition?.name === 'settings.section')
102
104
  const header = registrations.find((entry) => entry.definition?.name === 'conversation.session.header.actions')
105
+ const hero = registrations.find((entry) => entry.definition?.name === 'conversation.input.right')
103
106
  check('settings panel registered', registration !== undefined)
104
107
  check('header chip registered', header !== undefined)
108
+ check('new-conversation picker registered', hero !== undefined)
109
+ // The slot renders only when a Session exists, and a Session-scoped entry gets
110
+ // its id through `inject`; without this the picker would render with no session
111
+ // to read or bind.
112
+ check('the picker is handed its Session id',
113
+ typeof hero?.definition?.inject === 'function' && hero.definition.inject('session-x').sessionId === 'session-x',
114
+ typeof hero?.definition?.inject)
105
115
 
106
116
  const definition = registration?.definition ?? {}
107
117
  check('slot name', definition.name === 'settings.section', String(definition.name))
@@ -174,7 +184,7 @@ function expand(node) {
174
184
  }
175
185
 
176
186
  // ---- the rendered menu (seeded through the documented `preview` test seam) --
177
- const FOOTER = '装备栏只决定「这个对话能用哪些工具」:切换后下一条消息即按新清单组装,随时可以切回「全部」;新对话则从「设置 → 装备栏」的默认套装开始。它作用于工具(tool)这一层——界面面板、宿主服务类插件不注册工具,因此不受影响。'
187
+ const FOOTER = '装备栏只决定「这个对话能用哪些工具」:切换后下一条消息即按新清单组装,随时可以切回「全部」;装备栏作用于工具(tool)这一层——界面面板、宿主服务类插件不注册工具,因此不受影响。'
178
188
  const fixture = {
179
189
  ok: true,
180
190
  source: 'C:/fake/.dsh/loadout.json',
@@ -207,6 +217,7 @@ const fixture = {
207
217
  visibleTools: 12, visibleTokens: 860,
208
218
  totalTools: 31, totalTokens: 2410, savedTokens: 1550, unpriced: 0,
209
219
  },
220
+ baseTokens: { all: 2410, dsh: 1640 },
210
221
  }
211
222
 
212
223
  let menu
@@ -251,8 +262,13 @@ if (menu !== undefined) {
251
262
  // Presets are back from 0.12: they are the quickest way to start a known kind
252
263
  // of work, they stay read-only, and each one now says what it leaves out.
253
264
  check('menu lists the presets again', allText.includes('预设套装(5)') && allText.includes('写作') && allText.includes('仅聊天'), allText.slice(0, 400))
254
- check('加挂 counts extras', allText.includes('加挂(1)'), allText.slice(0, 400))
265
+ // 加挂 reads as "in this conversation / plugins there are at all", so the
266
+ // group answers "how much room is left" without opening it — and under 全部
267
+ // it reads 2/2 rather than 0/2, because everything is already in.
268
+ check('加挂 counts in-conversation plugins of all plugins', allText.includes('加挂(2/2)'), allText.slice(0, 400))
255
269
  check('footer text is the approved sentence', allText.includes(FOOTER))
270
+ check('footer carries the before-you-start reminder',
271
+ allText.includes('重要提醒') && allText.includes('建议新建对话时直接选择合适套装'), allText.slice(-600))
256
272
  check('the menu shows the note with the kit name', allText.includes('我的套(用于视频制作)'), allText.slice(0, 400))
257
273
  check('the save panel can be cancelled', source.includes("setSaveNote('')"), 'the 取消 button does not reset the save panel')
258
274
 
@@ -297,6 +313,257 @@ if (menu !== undefined) {
297
313
  const kitsOpenText = textOf(kitsOpen)
298
314
  check('我的套装 expands to the other kits', kitsOpenText.includes('另一套'), kitsOpenText.slice(0, 400))
299
315
  check('新建套装 is offered under 我的套装', kitsOpenText.includes('+ 新建套装'), kitsOpenText.slice(0, 400))
316
+
317
+ // Saving a kit and starting to use it are two decisions. The menu offers both,
318
+ // and only the second one can change what this conversation's tools are.
319
+ check('the builder offers save without switching',
320
+ source.includes("onSaveKeep: () => saveBuilder('keep')"), 'no 保存 button next to 保存并启用')
321
+ check('the builder labels the switching button', source.includes("saveLabel: '保存并启用'"))
322
+ check('the switching button warns it rewrites the tools',
323
+ source.includes('工具清单会变,这个对话下一条消息要按未命中价重新计算一次'), '保存并启用 has no hover text')
324
+ const keepBranch = source.slice(source.indexOf("if (mode === 'keep')"), source.indexOf('const appliedKit'))
325
+ check('save-only leaves the current selection alone', keepBranch.includes('return'), keepBranch.slice(0, 200))
326
+ }
327
+
328
+ // ---- the price of a switch, next to the switch ------------------------------
329
+ // Switching rewrites the tool definitions, so everything after them in the prefix
330
+ // is paid again at the miss price. The number is meaningless without the context
331
+ // that produced it, so the status line names the size too.
332
+ const priced = { ...fixture, context: { tokens: 200000 } }
333
+ let pricedMenu
334
+ try {
335
+ pricedMenu = expand(header.component({
336
+ sessionId: 'session-1',
337
+ preview: { open: true, data: priced, groups: { kits: true, presets: true, extras: true, applied: false } },
338
+ }))
339
+ } catch (error) {
340
+ failures.push(`priced menu render threw: ${error instanceof Error ? error.message : String(error)}`)
341
+ }
342
+
343
+ if (pricedMenu !== undefined) {
344
+ const pricedText = textOf(pricedMenu)
345
+ check('the status line states the context size', pricedText.includes('上下文 ~200k'), pricedText.slice(0, 300))
346
+ // Every row states the tool table it would install. That is what differs
347
+ // between 全部 / 仅内置 / each kit; the switch bill is the same number for all
348
+ // of them, so it lives once in the footer instead of on every row.
349
+ check('an inactive kit states its tool tokens', pricedText.includes('≈420 tokens'), pricedText.slice(0, 900))
350
+ const activePriced = nodesIn(pricedMenu, (node) => node.props?.role === 'menuitem' && textOf(node).includes('我的套'))
351
+ check('the kit already in use still states its tokens', textOf(activePriced[0]).includes('≈860 tokens'), textOf(activePriced[0]))
352
+ check('the default kit is marked after its name', textOf(activePriced[0]).includes('(默认)'), textOf(activePriced[0]))
353
+
354
+ // 全部 and 仅内置 pay for different tool tables, and that difference is the
355
+ // reason to pick one over the other.
356
+ const allRow = nodesIn(pricedMenu, (node) => node.props?.role === 'menuitem' && textOf(node).trim().startsWith('全部'))
357
+ check('全部 states its tool tokens', allRow.length === 1 && textOf(allRow[0]).includes('≈2.4k tokens'), JSON.stringify(allRow.map(textOf)))
358
+ const dshRow = nodesIn(pricedMenu, (node) => node.props?.role === 'menuitem' && textOf(node).trim().startsWith('仅内置'))
359
+ check('仅内置 states its own, smaller tool table', dshRow.length === 1 && textOf(dshRow[0]).includes('≈1.6k tokens'), JSON.stringify(dshRow.map(textOf)))
360
+
361
+ // The bill is stated once, under the reminder — not per row. A tooltip has to
362
+ // be found; the reminder is the paragraph people actually read.
363
+ check('the footer states the bill under the reminder',
364
+ pricedText.includes('本对话上下文(约 200k tokens),下一条未命中价≈¥0.30,之后恢复至缓存价。'),
365
+ pricedText.slice(-400))
366
+ // The bill is the one line people are meant to notice; at 11px grey it read as
367
+ // a footnote to the reminder above it.
368
+ const billLine = nodesIn(pricedMenu, (node) => node.type === 'div' &&
369
+ node.props?.style?.fontWeight === 600 && textOf(node).includes('本对话上下文'))
370
+ check('the bill line is bold and dark', billLine.length === 1, `${billLine.length}`)
371
+ const rowsWithOldTip = nodesIn(pricedMenu, (node) => node.props?.role === 'menuitem' &&
372
+ typeof node.props?.title === 'string' && node.props.title.includes('按未命中价重新计算'))
373
+ check('no row repeats the bill as a tooltip', rowsWithOldTip.length === 0,
374
+ JSON.stringify(rowsWithOldTip.map(textOf)))
375
+
376
+ // What new conversations start from is a global decision and belongs to
377
+ // 设置 → 装备栏. This menu is scoped to one conversation, so it must not grow
378
+ // a control that writes `defaultKit` — that would be the same mistake as
379
+ // letting the picker edit kits.
380
+ const defaultButtons = nodesIn(pricedMenu, (node) => node.type === 'button' &&
381
+ ['下次对话', '✓ 新对话', '设为默认'].includes(textOf(node).trim()))
382
+ check('the conversation menu cannot set the default', defaultButtons.length === 0,
383
+ defaultButtons.map((node) => textOf(node)).join('|'))
384
+ check('the conversation menu does not mention 下次对话', !pricedText.includes('下次对话'),
385
+ pricedText.slice(0, 300))
386
+
387
+ // Below 20k the switch is lost in the noise, and a number there would be noise.
388
+ const shortMenu = expand(header.component({
389
+ sessionId: 'session-1',
390
+ preview: { open: true, data: { ...fixture, context: { tokens: 5000 } }, groups: { kits: true, presets: true, extras: false } },
391
+ }))
392
+ const shortText = textOf(shortMenu)
393
+ const shortRows = nodesIn(shortMenu, (node) => node.props?.role === 'menuitem').map(textOf).join('|')
394
+ check('a short conversation quotes no price on the rows', !shortRows.includes('≈¥'), shortRows.slice(0, 400))
395
+ check('a short conversation still states tool tokens', shortRows.includes('≈860 tokens'), shortRows.slice(0, 400))
396
+ // The footer does not use that floor: it is one line whether the number is
397
+ // ¥0.008 or ¥0.30, and it is the sentence that explains the rows.
398
+ check('the footer still states the bill, however small',
399
+ shortText.includes('本对话上下文(约 5.0k tokens),下一条未命中价≈¥0.00'), shortText.slice(-300))
400
+
401
+ // A conversation already on the 全部 base: every plugin is in, so 加挂 reads
402
+ // full — it used to read 0/2 there, which looked like nothing had loaded.
403
+ const fullMenu = expand(header.component({
404
+ sessionId: 'session-1',
405
+ preview: {
406
+ open: true,
407
+ data: {
408
+ ...fixture,
409
+ selection: { base: 'all', kit: null, extras: [] },
410
+ appliedPlugins: ['alpha', 'demo'],
411
+ label: '全部',
412
+ context: { tokens: 200000 },
413
+ },
414
+ groups: { kits: false, presets: false, extras: false, applied: false },
415
+ },
416
+ }))
417
+ const fullText = textOf(fullMenu)
418
+ check('加挂 reads full under 全部', fullText.includes('加挂(2/2)'), fullText.slice(0, 300))
419
+ const fullRows = nodesIn(fullMenu, (node) => node.props?.role === 'menuitem')
420
+ // Under 全部 the pair still states two different tool tables: that is the only
421
+ // thing separating "hand me everything" from "hand me the built-ins".
422
+ const pricedBase = ['全部', '仅内置'].filter((label) => {
423
+ const row = fullRows.find((node) => textOf(node).replace(/^[\s✓]+/, '').startsWith(label))
424
+ return row !== undefined && textOf(row).includes('≈') && textOf(row).includes('tokens')
425
+ })
426
+ check('both base rows state their tool tokens, even the one in use', pricedBase.length === 2,
427
+ JSON.stringify(fullRows.map(textOf)))
428
+ }
429
+
430
+ // ---- the blank-conversation picker (composer row, next to 标准模式) ----------
431
+ // This is the one place a switch is free: the agent does not exist yet, so no
432
+ // request prefix has been built and nothing can be re-billed.
433
+ try {
434
+ // Collapsed, the chip says what it is and keeps the sentence for the tooltip:
435
+ // the composer row is shared with 权限/计划 and has no room for prose.
436
+ const collapsed = expand(hero.component({ sessionId: 'session-new', preview: { blank: true, data: fixture } }))
437
+ const collapsedText = textOf(collapsed)
438
+ check('the chip says what it chooses and names the kit',
439
+ collapsedText.includes('选择对话套装') && collapsedText.includes('我的套'), collapsedText)
440
+ check('the sentence is not printed on the row', !collapsedText.includes('这个新对话会用'), collapsedText)
441
+ const tipButton = nodesIn(collapsed, (node) => typeof node.props?.title === 'string' && node.props.title.includes('这个新对话会用'))
442
+ check('the sentence rides on the chip as its tooltip', tipButton.length === 1, JSON.stringify(nodesIn(collapsed, (node) => node.props?.title)))
443
+ check('the tooltip reads as a sentence about this conversation',
444
+ tipButton[0]?.props?.title === '这个新对话会用「我的套」套装', String(tipButton[0]?.props?.title))
445
+
446
+ // A pick on an unsent screen is a draft: a later visit must start clean, so the
447
+ // first look asks the host to drop whatever a previous visit left behind.
448
+ check('a new visit releases the previous visit\'s draft',
449
+ source.includes("action: 'release'") && source.includes('next.draft === true'),
450
+ 'the picker never releases a draft')
451
+
452
+ // With no default set, the collapsed label is 全部.
453
+ const noDefault = expand(hero.component({
454
+ sessionId: 'session-new',
455
+ preview: { blank: true, data: { ...fixture, defaultKit: '' } },
456
+ }))
457
+ const noDefaultText = textOf(noDefault)
458
+ check('with no default, the chip says 全部',
459
+ noDefaultText.includes('全部') && !noDefaultText.includes('我的套'), noDefaultText)
460
+ check('the collapsed control is a real button',
461
+ nodesIn(noDefault, (node) => node.props?.['aria-expanded'] === 'false').length === 1,
462
+ noDefaultText)
463
+
464
+ // Opened: 全部 / 仅内置 / the two groups, groups still folded.
465
+ const opened = expand(hero.component({
466
+ sessionId: 'session-new',
467
+ preview: { blank: true, data: fixture, open: true },
468
+ }))
469
+ const openedText = textOf(opened)
470
+ check('opened, it lists 全部 and 仅内置', openedText.includes('全部') && openedText.includes('仅内置'), openedText)
471
+ check('opened, it groups presets and my kits', openedText.includes('预设套装') && openedText.includes('我的套装'), openedText)
472
+ check('both kit groups start folded', !openedText.includes('写作') && !openedText.includes('另一套'), openedText)
473
+ check('folded groups still say how many they hold',
474
+ openedText.includes('(5)') && openedText.includes('(2)'), openedText)
475
+ // A folded group would otherwise answer "nothing chosen" on a fresh screen:
476
+ // the default kit stays visible and is marked the way the header menu marks it.
477
+ check('a folded group still shows the default kit',
478
+ openedText.includes('我的套(用于视频制作)(默认)') && !openedText.includes('另一套'), openedText)
479
+ const presetFold = expand(hero.component({
480
+ sessionId: 'session-new',
481
+ preview: { blank: true, data: { ...fixture, defaultKit: 'chat' }, open: true },
482
+ }))
483
+ check('a folded preset group shows the default preset too',
484
+ textOf(presetFold).includes('仅聊天(默认)') && !textOf(presetFold).includes('写作'), textOf(presetFold))
485
+ check('the collapsed chip stays plain',
486
+ !collapsedText.includes('(默认)'), collapsedText)
487
+
488
+ // The picker answers one question — which kit this conversation starts with.
489
+ // Creating, editing and choosing the default are someone else's job (设置), and
490
+ // a picker that also offered them would be a menu pretending to be a page.
491
+ const pickerText = textOf(hero.component({
492
+ sessionId: 'session-new',
493
+ preview: { blank: true, data: fixture, open: true, groups: { presets: true, kits: true } },
494
+ }))
495
+ check('the picker does not create kits', !pickerText.includes('新建套装'), pickerText.slice(0, 300))
496
+ check('the picker does not edit kits', !pickerText.includes('编辑') && !pickerText.includes('删除'), pickerText.slice(0, 300))
497
+ check('the picker does not set the default', !pickerText.includes('设为默认') && !pickerText.includes('下次对话'),
498
+ pickerText.slice(0, 300))
499
+
500
+ // Opening the list must not reflow the page, and it opens upward: the composer
501
+ // row sits at the bottom of the window.
502
+ const panel = nodesIn(opened, (node) => node.props?.style?.maxHeight === 320)
503
+ check('the list floats instead of pushing the page',
504
+ panel.length === 1 && panel[0]?.props?.style?.position === 'absolute', JSON.stringify(panel.map((node) => node.props.style)))
505
+ check('the list opens upward', String(panel[0]?.props?.style?.bottom ?? '').includes('100%'), JSON.stringify(panel[0]?.props?.style))
506
+ check('the chip anchors the floating list',
507
+ opened?.props?.style?.position === 'relative', JSON.stringify(opened?.props?.style))
508
+
509
+ // `@dsh` is the wire value for 仅内置; it must never be shown to the user, not
510
+ // in the list and not in the sentence that confirms a pick.
511
+ check('仅内置 is never printed as its wire value',
512
+ !openedText.includes('@dsh') && source.includes("if (name === DEFAULT_DSH) return '仅内置'"),
513
+ openedText)
514
+
515
+ // A kit inside 我的套装 is selectable once that group is opened, and picking it
516
+ // binds this conversation only.
517
+ const binds = []
518
+ fetchImpl = (url, init) => {
519
+ binds.push({ url, body: JSON.parse(String(init?.body ?? '{}')) })
520
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({ ...fixture, bound: true, binding: 'other' }) })
521
+ }
522
+ const kitsOpen = expand(hero.component({
523
+ sessionId: 'session-new',
524
+ preview: { blank: true, data: fixture, open: true, groups: { presets: false, kits: true } },
525
+ }))
526
+ const kitRows = nodesIn(kitsOpen, (node) => node.props?.role === 'menuitem')
527
+ const otherRow = kitRows.find((node) => textOf(node).includes('另一套'))
528
+ check('my kits open to their rows', otherRow !== undefined, JSON.stringify(kitRows.map(textOf)))
529
+ otherRow?.props?.onClick?.()
530
+ check('picking a kit binds this conversation only',
531
+ binds.length === 1 && binds[0]?.body?.action === 'bind' && binds[0]?.body?.sessionId === 'session-new' && binds[0]?.body?.name === 'other',
532
+ JSON.stringify(binds))
533
+ check('picking a kit leaves the default alone',
534
+ binds[0]?.body?.base === undefined && binds[0]?.body?.apply === undefined && binds[0]?.body?.default === undefined,
535
+ JSON.stringify(binds[0]?.body))
536
+
537
+ // A bound conversation shows its own pick, not the default.
538
+ const bound = expand(hero.component({
539
+ sessionId: 'session-new',
540
+ preview: { blank: true, data: { ...fixture, bound: true, binding: 'other' } },
541
+ }))
542
+ check('a bound conversation names its own kit', textOf(bound).includes('另一套'), textOf(bound))
543
+
544
+ // A conversation that borrowed the default is not bound: the chip has to show
545
+ // the current default even though the store still holds the kit it was given
546
+ // when it was created.
547
+ const borrowed = expand(hero.component({
548
+ sessionId: 'session-new',
549
+ preview: { blank: true, data: { ...fixture, bound: false, binding: 'other' } },
550
+ }))
551
+ check('a borrowed kit does not count as this conversation\'s pick',
552
+ textOf(borrowed).includes('我的套') && !textOf(borrowed).includes('另一套'), textOf(borrowed))
553
+ check('the picker can ask the host to catch up with the default',
554
+ source.includes('follow: true') && source.includes("String(next.binding ?? '') !== wanted"),
555
+ 'no catch-up request in the picker')
556
+ // The catch-up must hang on "this screen is blank", not on "this conversation
557
+ // never picked": a conversation created under an older default carries a real
558
+ // binding, and the pill used to show it as if it had been chosen here.
559
+ check('the catch-up is gated on a blank screen, not on the binding',
560
+ source.includes('if (blank === true)') && !source.includes('next.bound !== true &&'),
561
+ 'the catch-up still waits for a missing binding')
562
+
563
+ // Once the conversation has a message the hero is gone, and so is the picker.
564
+ check('the picker leaves with the hero', hero.component({ sessionId: 'session-new', preview: { blank: false, data: fixture } }) === null)
565
+ } catch (error) {
566
+ failures.push(`hero picker render threw: ${error instanceof Error ? error.message : String(error)}`)
300
567
  }
301
568
 
302
569
  // ---- the chip stays a single button while closed ---------------------------
@@ -335,7 +602,21 @@ try {
335
602
  // 全部 / 仅内置 sit in the same list as the kits, so they are rendered as cards
336
603
  // with the same two actions; presets are read-only and carry no save-as button.
337
604
  // The default section says out loud that it cannot reach an open conversation.
338
- check('settings says the default only affects new conversations', panelText.includes('只影响之后新建的对话'), panelText.slice(0, 400))
605
+ check('settings says the default only affects new conversations',
606
+ panelText.includes('只影响新建对话') && panelText.includes('当前对话在「装备栏」切换'), panelText.slice(0, 400))
607
+ // The title and its caveat are one thought; the note sits beside the heading
608
+ // instead of becoming a paragraph of its own above the cards.
609
+ check('the default heading carries its caveat on the same line',
610
+ panelText.includes('新对话默认套装只影响新建对话'), panelText.slice(0, 400))
611
+ // One text node, not two spans: the second span's own 0.6 opacity multiplied
612
+ // with the heading's 0.6, so the same line came out in two shades.
613
+ const splitCaveat = nodesIn(panel, (node) => textOf(node) === '只影响新建对话')
614
+ check('the caveat is not a separately styled node', splitCaveat.length === 0,
615
+ `${splitCaveat.length} · 只影响新建对话`)
616
+ // 全部 / 仅内置 are the only two cards that had no number; they price their own
617
+ // tool tables, which is also what the header menu's rows now show.
618
+ check('the base cards state their tool tokens',
619
+ panelText.includes('≈2.4k tokens') && panelText.includes('≈1.6k tokens'), panelText.slice(0, 500))
339
620
  const copyButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '复制切换命令')
340
621
  const defaultButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '设为默认')
341
622
  // 2 defaults + 3 presets + 2 user kits — every card carries both actions.
@@ -347,6 +628,16 @@ try {
347
628
  const deleteButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '删除')
348
629
  check('only my kits can be deleted', deleteButtons.length === 2, `${deleteButtons.length} · 删除@${panelText.indexOf('删除')} · ${panelText.slice(0, 200)}`)
349
630
 
631
+ // A page that edits defaults has no business quoting what a switch would cost
632
+ // *this* conversation — that number belongs to the conversation's own menu.
633
+ check('settings does not price a switch', !panelText.includes('切换一次大概多贵') && !panelText.includes('未命中 ¥/百万'), panelText.slice(0, 600))
634
+ check('settings explains the numbers are the last live conversation\'s',
635
+ panelText.includes('设置页不绑定具体会话,上面的数字来自最近活跃的那个对话'), panelText.slice(0, 300))
636
+ check('settings says where a single conversation is switched',
637
+ panelText.includes('当前对话在「装备栏」切换'), panelText.slice(0, 300))
638
+ check('settings names the other two places a kit can be chosen',
639
+ panelText.includes('新建时也可选套装'), panelText.slice(0, 300))
640
+
350
641
  // Presets start collapsed, and while collapsed they behave like 我的套装: the
351
642
  // one that is the default stays visible so the page still answers "a new
352
643
  // conversation starts with this".
@@ -412,12 +703,14 @@ try {
412
703
  applyError = error
413
704
  }
414
705
  check('apply survives a missing slot', applyError === undefined, String(applyError))
415
- check('the remaining surface still registers',
416
- keptRegistrations.length === 1 && keptRegistrations[0].name === 'settings.section',
706
+ check('the remaining surfaces still register',
707
+ keptRegistrations.length === 2
708
+ && keptRegistrations.some((entry) => entry.name === 'settings.section')
709
+ && keptRegistrations.some((entry) => entry.name === 'conversation.input.right'),
417
710
  JSON.stringify(keptRegistrations.map((entry) => entry.name)))
418
711
 
419
712
  if (failures.length === 0) {
420
- console.log('ok: client bundle loads, renders the new header menu (groups, check marks, tooltips, footer), and renders the settings panel')
713
+ console.log('ok: client bundle loads, renders the new-conversation picker, the header menu (groups, check marks, tooltips, footer), and the settings panel')
421
714
  process.exit(0)
422
715
  }
423
716
  console.log(`FAILED (${failures.length}):`)
@@ -118,6 +118,20 @@ function makeCtx() {
118
118
  built.commands = commands
119
119
  built.routes = routes
120
120
  built.sessions = { get: (sessionId) => sessions.get(sessionId) }
121
+ // The token meter publishes the prompt size as a session projection. The fake
122
+ // host answers from `projectionTokens`, so a test can move the number.
123
+ built.projectionTokens = { value: undefined }
124
+ built.sessionProjections = {
125
+ snapshot(session) {
126
+ const value = built.projectionTokens.value
127
+ return {
128
+ asOfSeq: 0,
129
+ values: value === undefined
130
+ ? {}
131
+ : { contextPressure: { pressureTokens: value, projectedTokens: value, contextWindow: 128000 } },
132
+ }
133
+ },
134
+ }
121
135
  built.sessionTable = sessions
122
136
  built.agentTable = agents
123
137
  return built
@@ -148,6 +162,7 @@ function childFor(dependencies, built, commands, routes) {
148
162
  // The plugin injects `sessions` optionally; a fake host without it must still
149
163
  // work (that is the 0.x path), so this mirrors the real `optional` contract.
150
164
  if (dependencies.includes('sessions')) child.sessions = built.sessions
165
+ if (dependencies.includes('sessionProjections')) child.sessionProjections = built.sessionProjections
151
166
  return child
152
167
  }
153
168
 
@@ -982,6 +997,141 @@ process.env.USERPROFILE = savedProfile
982
997
  process.env.HOME = savedHome
983
998
  rmSync(legacyHome, { recursive: true, force: true })
984
999
 
1000
+ // ---- the client is told how big the prompt is -------------------------------
1001
+ // A switch costs prompt size × the miss/hit price gap, so the host has to hand
1002
+ // over the token meter's own number — not a guess from the session file.
1003
+ const pricedAgent = makeAgent()
1004
+ sessionAgentOf.agent = pricedAgent
1005
+ const unsampled = await getState(pricedAgent.id)
1006
+ check('no sample yet means no context size', unsampled.body.context?.tokens === null, JSON.stringify(unsampled.body.context))
1007
+ ctx.projectionTokens.value = 150000
1008
+ const sampled = await getState(pricedAgent.id)
1009
+ check('the context size comes from contextPressure', sampled.body.context?.tokens === 150000, JSON.stringify(sampled.body.context))
1010
+ ctx.projectionTokens.value = undefined
1011
+ const meterGone = await getState(pricedAgent.id)
1012
+ check('a host without a token meter still answers', meterGone.body.context?.tokens === null, JSON.stringify(meterGone.body.context))
1013
+
1014
+ // ---- a blank conversation can choose its kit before it has an agent ---------
1015
+ // This is the free switch: no agent means no request prefix, so the choice is
1016
+ // stored as a binding and applied the moment the agent appears — before the
1017
+ // first prompt is built.
1018
+ const unknownPick = await post({ action: 'bind', sessionId: 'session-blank', name: 'pick-kit' })
1019
+ check('binding an unknown kit is refused', unknownPick.status === 404, `${unknownPick.status} ${unknownPick.body?.message ?? ''}`)
1020
+
1021
+ await ctx.registered.get('kit_create').execute(
1022
+ { name: 'pick-kit', title: '新建用', builtins: ['read'], plugins: [] }, exec(agentB))
1023
+ const bound = await post({ action: 'bind', sessionId: 'session-blank', name: 'pick-kit' })
1024
+ check('a blank conversation can be bound to a kit',
1025
+ bound.status === 200 && bound.body?.ok === true && bound.body?.bound === true && bound.body?.binding === 'pick-kit',
1026
+ JSON.stringify({ status: bound.status, bound: bound.body?.bound, binding: bound.body?.binding }))
1027
+
1028
+ const beforeAgent = await getState('session-blank')
1029
+ check('the binding is stored before the agent exists',
1030
+ beforeAgent.body?.bound === true && beforeAgent.body?.binding === 'pick-kit',
1031
+ JSON.stringify({ bound: beforeAgent.body?.bound, binding: beforeAgent.body?.binding }))
1032
+
1033
+ // The first request is assembled after this, so the pick costs nothing.
1034
+ const pickAgent = makeAgent(undefined, { id: 'session-blank' })
1035
+ sessionAgentOf.agent = pickAgent
1036
+ for (const handler of ctx.handlers.get('agent/created') ?? []) handler(pickAgent)
1037
+ const afterAgent = await getState('session-blank')
1038
+ check('the pick applies as soon as the agent appears',
1039
+ afterAgent.body?.binding === 'pick-kit' && afterAgent.body?.selection?.kit === 'pick-kit',
1040
+ JSON.stringify({ binding: afterAgent.body?.binding, selection: afterAgent.body?.selection }))
1041
+ check('the picked kit is what the first request will see',
1042
+ keptOf(pickAgent).includes('read'), keptOf(pickAgent).join(','))
1043
+ // The welcome screen can already have an agent behind it, and the picker still
1044
+ // has to know the pick was a draft — otherwise the release never fires and the
1045
+ // draft is what the next visit shows.
1046
+ const liveDraft = await getState('session-blank')
1047
+ check('a live conversation still reports its draft',
1048
+ liveDraft.body?.live === true && liveDraft.body?.draft === true,
1049
+ JSON.stringify({ live: liveDraft.body?.live, draft: liveDraft.body?.draft }))
1050
+
1051
+ const cleared = await post({ action: 'bind', sessionId: 'session-blank', name: '' })
1052
+ check('binding 全部 clears the pick', cleared.status === 200 && cleared.body?.bound === true && cleared.body?.binding === '',
1053
+ JSON.stringify({ bound: cleared.body?.bound, binding: cleared.body?.binding }))
1054
+ const clearedState = await getState('session-blank')
1055
+ check('the cleared pick reads back as 全部', clearedState.body?.bound === true && clearedState.body?.binding === '',
1056
+ JSON.stringify({ bound: clearedState.body?.bound, binding: clearedState.body?.binding }))
1057
+
1058
+ // ---- a welcome-screen pick is a draft, not a decision ----------------------
1059
+ // Leaving the screen without sending anything must reset it, exactly like the
1060
+ // host resets its own mode; a completed turn promotes the draft for real.
1061
+ const draftBind = await post({ action: 'bind', sessionId: 'session-draft', name: 'pick-kit' })
1062
+ check('a welcome-screen pick is marked as a draft', draftBind.body?.draft === true, JSON.stringify(draftBind.body?.draft))
1063
+ // The marker has to be on disk, not in this process: a restart used to launder
1064
+ // a draft into a decision, so the next visit showed a kit the user never chose.
1065
+ const draftStore = () => JSON.parse(readFileSync(storePath, 'utf8'))
1066
+ check('the draft marker reaches the store',
1067
+ draftStore().drafts?.['session-draft'] === 'pick-kit',
1068
+ JSON.stringify(draftStore().drafts))
1069
+ const draftState = await getState('session-draft')
1070
+ check('the draft reads back on the same visit',
1071
+ draftState.body?.draft === true && draftState.body?.binding === 'pick-kit',
1072
+ JSON.stringify({ draft: draftState.body?.draft, binding: draftState.body?.binding }))
1073
+
1074
+ const released = await post({ action: 'release', sessionId: 'session-draft' })
1075
+ check('leaving the welcome screen drops the draft',
1076
+ released.body?.draft === false && released.body?.bound === false && released.body?.binding === '',
1077
+ JSON.stringify({ draft: released.body?.draft, bound: released.body?.bound, binding: released.body?.binding }))
1078
+ check('the released draft leaves nothing behind in the store',
1079
+ draftStore().drafts?.['session-draft'] === undefined && draftStore().assignments['session-draft'] === undefined,
1080
+ JSON.stringify({ drafts: draftStore().drafts, assignment: draftStore().assignments['session-draft'] }))
1081
+ const afterRelease = await getState('session-draft')
1082
+ check('the next visit starts at the default',
1083
+ afterRelease.body?.draft !== true && afterRelease.body?.bound === false,
1084
+ JSON.stringify({ draft: afterRelease.body?.draft, bound: afterRelease.body?.bound }))
1085
+
1086
+ await post({ action: 'bind', sessionId: 'session-draft', name: 'pick-kit' })
1087
+ for (const handler of ctx.handlers.get('session/event') ?? []) handler({ id: 'session-draft' }, { type: 'turn/end' })
1088
+ const promoted = await getState('session-draft')
1089
+ check('a completed turn promotes the draft into a real binding',
1090
+ promoted.body?.draft !== true && promoted.body?.binding === 'pick-kit',
1091
+ JSON.stringify({ draft: promoted.body?.draft, binding: promoted.body?.binding }))
1092
+
1093
+ await post({ action: 'bind', sessionId: 'session-draft-2', name: 'pick-kit' })
1094
+ for (const handler of ctx.handlers.get('session/disposed') ?? []) handler({ id: 'session-draft-2' })
1095
+ const disposed = await getState('session-draft-2')
1096
+ check('a disposed welcome screen takes its draft with it',
1097
+ disposed.body?.draft !== true, JSON.stringify(disposed.body?.draft))
1098
+
1099
+ // ---- a default is a loan, not a pick ---------------------------------------
1100
+ // Creating a conversation while a default is set used to write that default into
1101
+ // the conversation's binding as if the user had chosen it. After the default
1102
+ // moved, the welcome screen kept showing the old kit under a 「默认」 label.
1103
+ const loanAgent = makeAgent(undefined, { id: 'session-loan' })
1104
+ fireCreated(loanAgent)
1105
+ const loaned = await getState('session-loan')
1106
+ check('a conversation that borrowed the default does not count as bound',
1107
+ loaned.body?.bound === false && loaned.body?.binding === 'coding',
1108
+ JSON.stringify({ bound: loaned.body?.bound, binding: loaned.body?.binding }))
1109
+ check('the loan is marked as such in the store',
1110
+ draftStore().auto?.['session-loan'] === true && draftStore().drafts?.['session-loan'] === undefined,
1111
+ JSON.stringify({ auto: draftStore().auto, drafts: draftStore().drafts }))
1112
+
1113
+ // Changing the default still touches no conversation...
1114
+ await ctx.registered.get('kit_set_default').execute({ name: 'pick-kit' }, exec(loanAgent))
1115
+ const stillLoaned = await getState('session-loan')
1116
+ check('a new default leaves a borrowed conversation on its old kit',
1117
+ stillLoaned.body?.binding === 'coding', JSON.stringify(stillLoaned.body?.binding))
1118
+
1119
+ // ...the welcome screen catches it up instead, and the catch-up is still a loan.
1120
+ const caughtUp = await post({ action: 'bind', sessionId: 'session-loan', name: 'pick-kit', follow: true })
1121
+ check('the follow-up moves a borrowed conversation to the new default',
1122
+ caughtUp.body?.binding === 'pick-kit' && caughtUp.body?.bound === false && caughtUp.body?.draft === false,
1123
+ JSON.stringify({ binding: caughtUp.body?.binding, bound: caughtUp.body?.bound, draft: caughtUp.body?.draft }))
1124
+ check('a follow-up writes no draft',
1125
+ draftStore().drafts?.['session-loan'] === undefined && draftStore().auto?.['session-loan'] === true,
1126
+ JSON.stringify({ drafts: draftStore().drafts, auto: draftStore().auto }))
1127
+
1128
+ // The first turn turns the loan into this conversation's own kit.
1129
+ for (const handler of ctx.handlers.get('session/event') ?? []) handler({ id: 'session-loan' }, { type: 'turn/end' })
1130
+ const settled = await getState('session-loan')
1131
+ check('the first turn pins the borrowed kit',
1132
+ settled.body?.bound === true && settled.body?.binding === 'pick-kit' && draftStore().auto?.['session-loan'] === undefined,
1133
+ JSON.stringify({ bound: settled.body?.bound, binding: settled.body?.binding, auto: draftStore().auto }))
1134
+
985
1135
  // ---- report ----------------------------------------------------------------
986
1136
  rmSync(scratch, { recursive: true, force: true })
987
1137
  if (failures.length === 0) {
@@ -1,24 +0,0 @@
1
- /**
2
- * Is there a host service that maps a session id to its live agent?
3
- */
4
- import { readFileSync, readdirSync } from 'node:fs'
5
-
6
- const BASE = 'C:/Users/JungleYXu/AppData/Local/Programs/DeepSeek Harness/resources/app.asar/dsh/node_modules/@deepseek-ai'
7
- const packages = readdirSync(BASE)
8
- const pattern = /agentFor|bySession|sessionAgent|agentOf|resolveAgent/g
9
-
10
- for (const name of packages) {
11
- if (!name.startsWith('dsh-')) continue
12
- let text
13
- try {
14
- text = readFileSync(`${BASE}/${name}/lib/index.js`, 'utf8')
15
- } catch {
16
- continue
17
- }
18
- const hits = [...new Set([...text.matchAll(pattern)].map((m) => m[0]))]
19
- if (hits.length === 0) continue
20
- const index = text.search(pattern)
21
- console.log(`\n### ${name} :: ${hits.join(', ')}`)
22
- console.log(text.slice(Math.max(0, index - 200), index + 260).replace(/\s+/g, ' '))
23
- }
24
-
@@ -1,25 +0,0 @@
1
- /**
2
- * How does host code get a session id from an agent object?
3
- */
4
- import { readFileSync } from 'node:fs'
5
-
6
- const BASE = 'C:/Users/JungleYXu/AppData/Local/Programs/DeepSeek Harness/resources/app.asar/dsh/node_modules/@deepseek-ai'
7
- const files = [
8
- 'dsh-tools/lib/index.js',
9
- 'dsh-agent/lib/index.js',
10
- 'dsh-session/lib/index.js',
11
- 'dsh-subagent/lib/index.js',
12
- ]
13
-
14
- for (const rel of files) {
15
- let text
16
- try {
17
- text = readFileSync(`${BASE}/${rel}`, 'utf8')
18
- } catch (error) {
19
- console.log(`skip ${rel}: ${error.code}`)
20
- continue
21
- }
22
- const hits = [...text.matchAll(/agent\.session[A-Za-z]*/g)].map((m) => m[0])
23
- const unique = [...new Set(hits)]
24
- console.log(`${rel} -> ${unique.join(', ') || '(none)'}`)
25
- }