dsh-context-compression-improved 0.5.1 → 0.5.3

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.
Files changed (57) hide show
  1. package/.gitattributes +1 -0
  2. package/CHANGELOG.ja.md +144 -83
  3. package/CHANGELOG.ko.md +143 -82
  4. package/CHANGELOG.md +278 -212
  5. package/CHANGELOG.zh.md +131 -77
  6. package/docs/installation.md +103 -103
  7. package/docs/installation.zh.md +100 -100
  8. package/package.json +1 -1
  9. package/packages/selector/cordis.patch.yml +5 -6
  10. package/packages/selector/lib/advisor-state.js +4 -231
  11. package/packages/selector/lib/client.d.ts +0 -24
  12. package/packages/selector/lib/client.js +6 -501
  13. package/packages/selector/lib/index.d.ts +4 -10
  14. package/packages/selector/lib/index.js +16 -234
  15. package/packages/selector/lib/pruner.d.ts +13 -248
  16. package/packages/selector/lib/pruner.js +148 -552
  17. package/packages/selector/src/client/EstimatorControls.tsx +0 -101
  18. package/packages/selector/src/client/index.ts +0 -17
  19. package/packages/selector/src/client/locales.ts +0 -38
  20. package/packages/selector/src/client/preset-options.ts +3 -2
  21. package/packages/selector/src/client/settings-section.tsx +8 -17
  22. package/packages/selector/src/index.ts +24 -271
  23. package/packages/selector/src/profiles.ts +4 -27
  24. package/packages/selector/src/pruner/state.ts +2 -25
  25. package/packages/selector/src/pruner.ts +75 -403
  26. package/packages/selector/src/runtime/audit.ts +27 -21
  27. package/packages/selector/src/runtime/config.ts +6 -32
  28. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -188
  29. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +149 -133
  30. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -419
  31. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
  32. package/packages/selector/src/runtime/types.ts +0 -17
  33. package/packages/selector/tests/advisor-report.host.spec.ts +223 -223
  34. package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
  35. package/packages/selector/tests/public/package-contract.client.spec.ts +20 -0
  36. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
  37. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -272
  38. package/packages/selector/tests/runtime/advisor.spec.ts +226 -226
  39. package/packages/selector/tests/runtime/audit.spec.ts +35 -21
  40. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -30
  41. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
  42. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
  43. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
  44. package/packages/selector/tests/settings-seat.client.spec.ts +45 -14
  45. package/scripts/toolclass-corpus-replay.mjs +281 -281
  46. package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
  47. package/packages/selector/src/client/review-scope.ts +0 -16
  48. package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
  49. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
  50. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
  51. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
  52. package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
  53. package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
  54. package/packages/selector/tests/review-routes.host.spec.ts +0 -290
  55. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
  56. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
  57. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Upgrade safety for the retired review gate's settings keys.
3
+ *
4
+ * The gate is gone, but its keys are PERSISTED: a live settings document still
5
+ * carries `reviewMode: false`, and both the runtime parser and the browser
6
+ * decoder reject unknown keys. Removing the keys from the accepted set would
7
+ * therefore make the plugin fail to load (runtime) or report the whole settings
8
+ * card as unreadable (client). This spec pins the accept-and-ignore contract in
9
+ * both directions, and pins that the keys never reach the resolved policy.
10
+ */
11
+ import { describe, expect, it } from 'vitest'
12
+ import {
13
+ parsePresetOptionsSettings,
14
+ resolveConfig,
15
+ resolvePolicy,
16
+ } from '../../src/runtime/config.ts'
17
+ import { decodePresetOptionsSettings } from '../../src/profiles.ts'
18
+
19
+ /** The exact legacy section a pre-0.5.2 install has on disk. */
20
+ const LEGACY_SECTION = {
21
+ estimatorMode: 'host',
22
+ estimatorProvider: 'local-35b',
23
+ estimatorModel: 'Qwen3.6-35B-A3B',
24
+ estimatorBaseUrl: 'http://192.168.100.242:8200/v1',
25
+ reviewMode: false,
26
+ }
27
+
28
+ describe('retired review-gate keys are accepted and ignored (runtime)', () => {
29
+ it('parses the legacy section without throwing and keeps the live keys', () => {
30
+ const parsed = parsePresetOptionsSettings(LEGACY_SECTION)
31
+ expect(parsed).toEqual({
32
+ estimatorMode: 'host',
33
+ estimatorProvider: 'local-35b',
34
+ estimatorModel: 'Qwen3.6-35B-A3B',
35
+ estimatorBaseUrl: 'http://192.168.100.242:8200/v1',
36
+ })
37
+ })
38
+
39
+ it('drops every retired key, even with values the old schema rejected', () => {
40
+ // The retired keys are not validated any more — a stale invalid value in an
41
+ // existing document must not be able to break the load.
42
+ const parsed = parsePresetOptionsSettings({
43
+ reviewMode: 'yes',
44
+ reviewTimeoutTurns: -1,
45
+ cacheHitDiscountAlpha: 'wide',
46
+ reviewHighImpactTokens: null,
47
+ })
48
+ expect(parsed).toEqual({})
49
+ })
50
+
51
+ it('still rejects a genuinely unknown key', () => {
52
+ expect(() => parsePresetOptionsSettings({ reviewModes: true })).toThrow(/unknown key/)
53
+ })
54
+
55
+ it('never lets a retired key reach the resolved policy matrix', () => {
56
+ // The section arrives as `unknown` from the settings document, so the cast
57
+ // mirrors the production path rather than widening the config surface.
58
+ const policy = resolvePolicy(
59
+ resolveConfig({
60
+ presetOptions: { reviewMode: true, reviewHighImpactTokens: 1 },
61
+ } as unknown as Parameters<typeof resolveConfig>[0]),
62
+ 'tokenpilot-inspired',
63
+ )
64
+ const matrix = policy.presetOptions
65
+ expect(matrix).toBeDefined()
66
+ for (const gone of ['reviewMode', 'reviewTimeoutTurns', 'cacheHitDiscountAlpha', 'reviewHighImpactTokens']) {
67
+ expect(Object.hasOwn(matrix as object, gone)).toBe(false)
68
+ }
69
+ // The surviving matrix is exactly the live capability set.
70
+ expect(Object.keys(matrix as object).sort()).toEqual([
71
+ 'advisor', 'dedupeToolResults', 'estimator', 'noNetSavingsGuard',
72
+ 'prefixStabilizer', 'readState', 'skipReductionRecovery', 'summaryLocator',
73
+ ])
74
+ })
75
+ })
76
+
77
+ describe('retired review-gate keys are accepted and ignored (client mirror)', () => {
78
+ it('decodes a legacy section instead of reporting the card unreadable', () => {
79
+ const decoded = decodePresetOptionsSettings(LEGACY_SECTION)
80
+ // `undefined` here would blank the settings card's presetOptions section.
81
+ expect(decoded).toEqual({
82
+ estimatorMode: 'host',
83
+ estimatorProvider: 'local-35b',
84
+ estimatorModel: 'Qwen3.6-35B-A3B',
85
+ estimatorBaseUrl: 'http://192.168.100.242:8200/v1',
86
+ })
87
+ })
88
+
89
+ it('tolerates legacy values the old decoder rejected', () => {
90
+ expect(decodePresetOptionsSettings({ reviewMode: 'yes', reviewHighImpactTokens: -5 })).toEqual({})
91
+ })
92
+
93
+ it('still refuses an unknown key', () => {
94
+ expect(decodePresetOptionsSettings({ reviewModes: true })).toBeUndefined()
95
+ })
96
+ })
@@ -0,0 +1,217 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ adviseCandidates,
4
+ computeBenefit,
5
+ DEFAULT_ADVICE_ALPHA,
6
+ DEFAULT_ADVICE_HIGH_IMPACT_TOKENS,
7
+ } from '../../../src/runtime/tokenpilot/benefit.ts'
8
+
9
+ /** Reference values follow the spec formulas exactly:
10
+ * R = Σ(before − after); penalty = (1−α)·tail; payback = penalty / (α·R);
11
+ * expectedSaving = α·R·max(0, Ŝ − payback). */
12
+
13
+ describe('computeBenefit', () => {
14
+ it('returns zero recovery and no payback for an empty batch', () => {
15
+ const result = computeBenefit([], { alpha: 0.1, tailTokens: 4000 })
16
+ expect(result.recoveredTokens).toBe(0)
17
+ expect(result.penaltyTokens).toBe(3600)
18
+ expect(result.paybackTurns).toBeUndefined()
19
+ expect(result.expectedSaving).toBeUndefined()
20
+ })
21
+
22
+ it('computes the single-candidate benefit with a known remaining-turn estimate', () => {
23
+ // R = 4000 − 400 = 3600; penalty = 0.9·4000 = 3600; per-turn = 0.1·3600 = 360
24
+ const result = computeBenefit(
25
+ [{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
26
+ { alpha: 0.1, tailTokens: 4000, remainingTurns: 12 },
27
+ )
28
+ expect(result.recoveredTokens).toBe(3600)
29
+ expect(result.penaltyTokens).toBeCloseTo(3600)
30
+ expect(result.paybackTurns).toBeCloseTo(10)
31
+ // α·R·max(0, 12 − 10) = 360·2
32
+ expect(result.expectedSaving).toBeCloseTo(720)
33
+ })
34
+
35
+ it('omits expectedSaving while Ŝ is unknown', () => {
36
+ const result = computeBenefit(
37
+ [{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
38
+ { alpha: 0.1, tailTokens: 4000 },
39
+ )
40
+ expect(result.paybackTurns).toBeCloseTo(10)
41
+ expect(result.expectedSaving).toBeUndefined()
42
+ })
43
+
44
+ it('merges candidates into one batch so the penalty is paid once', () => {
45
+ const result = computeBenefit(
46
+ [
47
+ { sourceSeq: 1, tokensBefore: 2000, tokensAfter: 500 },
48
+ { sourceSeq: 2, tokensBefore: 3000, tokensAfter: 1000 },
49
+ ],
50
+ { alpha: 0.1, tailTokens: 4000, remainingTurns: 20 },
51
+ )
52
+ expect(result.recoveredTokens).toBe(3500)
53
+ expect(result.penaltyTokens).toBeCloseTo(3600)
54
+ expect(result.paybackTurns).toBeCloseTo(3600 / 350)
55
+ expect(result.expectedSaving).toBeCloseTo(350 * (20 - 3600 / 350))
56
+ })
57
+
58
+ it('clamps a growing candidate to zero recovery instead of negative batch credit', () => {
59
+ const result = computeBenefit(
60
+ [{ sourceSeq: 3, tokensBefore: 100, tokensAfter: 400 }],
61
+ { alpha: 0.1, tailTokens: 4000 },
62
+ )
63
+ expect(result.recoveredTokens).toBe(0)
64
+ expect(result.paybackTurns).toBeUndefined()
65
+ })
66
+
67
+ it('stays finite as α → 0: no division, payback undefined, expectedSaving negative', () => {
68
+ const result = computeBenefit(
69
+ [{ sourceSeq: 4, tokensBefore: 4000, tokensAfter: 400 }],
70
+ { alpha: 0, tailTokens: 4000, remainingTurns: 12 },
71
+ )
72
+ expect(result.recoveredTokens).toBe(3600)
73
+ expect(result.paybackTurns).toBeUndefined()
74
+ // With zero per-turn saving the batch can only lose the refill penalty.
75
+ expect(result.expectedSaving).toBeCloseTo(-4000)
76
+ })
77
+
78
+ it('never reports a positive expectedSaving once Ŝ is inside the payback window', () => {
79
+ // payback = 10 turns; Ŝ = 5 → max(0, Ŝ − payback) = 0
80
+ const result = computeBenefit(
81
+ [{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
82
+ { alpha: 0.1, tailTokens: 4000, remainingTurns: 5 },
83
+ )
84
+ expect(result.expectedSaving).toBeCloseTo(0)
85
+ })
86
+
87
+ it('waives the refill penalty for a refill-exempt batch: payback 0, saving α·R·Ŝ', () => {
88
+ // Fresh-stage shaping: the content never entered the KV cache, so no
89
+ // refill penalty applies — the whole discounted recovery is pure gain.
90
+ const result = computeBenefit(
91
+ [{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
92
+ { alpha: 0.1, tailTokens: 4000, remainingTurns: 12, refillPenaltyExempt: true },
93
+ )
94
+ expect(result.recoveredTokens).toBe(3600)
95
+ expect(result.penaltyTokens).toBe(0)
96
+ expect(result.paybackTurns).toBe(0)
97
+ // α·R·max(0, Ŝ − 0) = 360·12
98
+ expect(result.expectedSaving).toBeCloseTo(4320)
99
+ })
100
+
101
+ it('keeps the refill penalty when the exemption flag is absent', () => {
102
+ const result = computeBenefit(
103
+ [{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
104
+ { alpha: 0.1, tailTokens: 4000 },
105
+ )
106
+ expect(result.penaltyTokens).toBeCloseTo(3600)
107
+ })
108
+ })
109
+
110
+ /** The shipped advice defaults are part of the contract: the audit and the
111
+ * report quote them, so a silent drift would change what the label means. */
112
+ describe('advice defaults', () => {
113
+ it('pins the shipped α and high-impact threshold', () => {
114
+ expect(DEFAULT_ADVICE_ALPHA).toBe(0.1)
115
+ expect(DEFAULT_ADVICE_HIGH_IMPACT_TOKENS).toBe(4_000)
116
+ })
117
+ })
118
+
119
+ describe('adviseCandidates', () => {
120
+ // The shipped threshold is exercised by its own case; every other case lifts
121
+ // it out of the way so one band can be asserted at a time.
122
+ const base = {
123
+ alpha: 0.1,
124
+ tailTokens: 4000,
125
+ highImpactTokens: 1_000_000,
126
+ }
127
+
128
+ it('returns undefined when no candidate carries a positive recovery', () => {
129
+ expect(adviseCandidates([], base)).toBeUndefined()
130
+ expect(adviseCandidates([{ sourceSeq: 1, tokensBefore: 100, tokensAfter: 400 }], base)).toBeUndefined()
131
+ })
132
+
133
+ it('labels a fast-payback batch profitable', () => {
134
+ // R = 3600, penalty = 3600, α·R = 360 → payback 10 ≤ 0.25·Ŝ requires Ŝ ≥ 40.
135
+ const advice = adviseCandidates(
136
+ [{ sourceSeq: 7, tokensBefore: 4_000, tokensAfter: 400 }],
137
+ { ...base, remainingTurns: 60 },
138
+ )
139
+ expect(advice?.band).toBe('profitable')
140
+ expect(advice?.priced).toBe(1)
141
+ expect(advice?.maxTokensBefore).toBe(4_000)
142
+ })
143
+
144
+ it('labels an unpriceable batch when α prices no recovery', () => {
145
+ const advice = adviseCandidates(
146
+ [{ sourceSeq: 7, tokensBefore: 4_000, tokensAfter: 400 }],
147
+ { ...base, alpha: 0, remainingTurns: 12 },
148
+ )
149
+ expect(advice?.band).toBe('unpriceable')
150
+ expect(advice?.benefit.expectedSaving).toBeCloseTo(-4000)
151
+ })
152
+
153
+ it('lets one high-impact candidate label the whole batch, ahead of a good payback', () => {
154
+ // The 8k candidate alone reaches the shipped threshold; the small sibling
155
+ // would be profitable on its own. One label per batch, high impact wins.
156
+ const advice = adviseCandidates(
157
+ [
158
+ { sourceSeq: 1, tokensBefore: 200, tokensAfter: 100 },
159
+ { sourceSeq: 2, tokensBefore: 8_192, tokensAfter: 1_000 },
160
+ ],
161
+ { ...base, highImpactTokens: DEFAULT_ADVICE_HIGH_IMPACT_TOKENS, alpha: 1, remainingTurns: 60 },
162
+ )
163
+ expect(advice?.band).toBe('high-impact')
164
+ expect(advice?.maxTokensBefore).toBe(8_192)
165
+ })
166
+
167
+ it('labels a slowly paying batch slow-payback when Ŝ is known', () => {
168
+ // R = 12000 → penalty 3600, α·R = 1200 → payback 3. Ŝ = 11 keeps
169
+ // 0.25·Ŝ = 2.75 strictly below the payback, so the batch is not profitable.
170
+ const advice = adviseCandidates(
171
+ [{ sourceSeq: 7, tokensBefore: 12_000, tokensAfter: 0 }],
172
+ { ...base, remainingTurns: 11 },
173
+ )
174
+ expect(advice?.band).toBe('slow-payback')
175
+ })
176
+
177
+ it('labels a batch that never pays back not-worth-it, and still returns its benefit', () => {
178
+ const advice = adviseCandidates(
179
+ [{ sourceSeq: 7, tokensBefore: 4_000, tokensAfter: 400 }],
180
+ { ...base, remainingTurns: 2 },
181
+ )
182
+ expect(advice?.band).toBe('not-worth-it')
183
+ expect(advice?.benefit.recoveredTokens).toBe(3600)
184
+ })
185
+
186
+ it('never closes a band on Ŝ: an unknown remaining-turn count keeps the batch priceable', () => {
187
+ const advice = adviseCandidates(
188
+ [{ sourceSeq: 7, tokensBefore: 4_000, tokensAfter: 400 }],
189
+ base,
190
+ )
191
+ // payback 10 > 1 and Ŝ is unknown → the profit bands cannot be argued, so
192
+ // the batch is labelled as not worth the cache break rather than fabricated.
193
+ expect(advice?.band).toBe('not-worth-it')
194
+ expect(advice?.benefit.expectedSaving).toBeUndefined()
195
+ })
196
+
197
+ it('prices a fresh-stage batch without the refill penalty', () => {
198
+ const advice = adviseCandidates(
199
+ [{ sourceSeq: 7, tokensBefore: 4_000, tokensAfter: 400 }],
200
+ { ...base, highImpactTokens: 1_000_000, stage: 'fresh', remainingTurns: 12 },
201
+ )
202
+ expect(advice?.benefit.penaltyTokens).toBe(0)
203
+ expect(advice?.band).toBe('profitable')
204
+ })
205
+
206
+ it('counts only the positively-recovering candidates it priced', () => {
207
+ const advice = adviseCandidates(
208
+ [
209
+ { sourceSeq: 1, tokensBefore: 100, tokensAfter: 400 },
210
+ { sourceSeq: 2, tokensBefore: 4_000, tokensAfter: 400 },
211
+ ],
212
+ { ...base, remainingTurns: 60 },
213
+ )
214
+ expect(advice?.priced).toBe(1)
215
+ expect(advice?.maxTokensBefore).toBe(4_000)
216
+ })
217
+ })
@@ -61,15 +61,14 @@ describe('tokenpilot-inspired preset', () => {
61
61
  prefixStabilizer: true,
62
62
  readState: true,
63
63
  estimator: { mode: '' },
64
- // Review pipeline (beta) ships off with its documented defaults.
65
- reviewMode: false,
66
- reviewTimeoutTurns: 6,
67
- cacheHitDiscountAlpha: 0.1,
68
- reviewHighImpactTokens: 4000,
69
64
  // Advisory advisor ships off with its documented defaults. The
70
65
  // resolved matrix intentionally gains this group so it follows the
71
66
  // estimator's resolved-consumption pattern; see
72
67
  // .agents/plans/ctx-relevance-advisor/spec.md decision record.
68
+ //
69
+ // The retired review gate's four keys are intentionally absent here:
70
+ // they are still ACCEPTED by the parser for upgrade safety but never
71
+ // reach the resolved policy (a reduction must not be gated).
73
72
  advisor: {
74
73
  mode: '',
75
74
  timeoutMs: 8000,
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * This spec locks WHERE the compression settings panel mounts: exactly ONE
5
5
  * seat — the standalone `settings.section` entry (设置 → 上下文压缩) — and
6
- * explicitly NOT the Plugins-section surfaces. History this pins against: the
7
- * 0.1.5 line first lost every entry (a lazy `ctx.get` of locale/settingsScope
8
- * raced the settings client and apply bailed), then showed the panel twice
9
- * (item card + tab on top of the standalone section). Both were fixed by the
10
- * declarative-inject + single-section shape below. When a future DSH line
11
- * moves the seat again, migrate src/client/index.ts AND this file together.
6
+ * explicitly NOT the Plugins-section surfaces, and NOT a `shell.overlay` float
7
+ * (the retired review panel's seat was removed with the gate). History this
8
+ * pins against: the 0.1.5 line first lost every entry (a lazy `ctx.get` of
9
+ * locale/settingsScope raced the settings client and apply bailed), then showed
10
+ * the panel twice (item card + tab on top of the standalone section). Both were
11
+ * fixed by the declarative-inject + single-section shape below. When a future
12
+ * DSH line moves the seat again, migrate src/client/index.ts AND this file
13
+ * together.
12
14
  */
13
15
  import { describe, expect, it, vi } from 'vitest'
14
16
  import type { Context as ClientContext } from '@deepseek-ai/cordis'
@@ -26,8 +28,15 @@ interface CapturedRegistration {
26
28
  readonly component: unknown
27
29
  }
28
30
 
29
- /** Drive apply against a stub host and capture every slot registration. */
30
- function collectRegistrations(): { declared: string[]; registrations: CapturedRegistration[] } {
31
+ /**
32
+ * Drive apply against a stub host and capture every slot registration.
33
+ * @param options - `registerThrows` models an older host that does not declare
34
+ * the seat: the slot boundary rejects the registration, exactly the shape the
35
+ * 0.1.2-era hosts had.
36
+ */
37
+ function collectRegistrations(
38
+ options: { readonly registerThrows?: boolean } = {},
39
+ ): { declared: string[]; registrations: CapturedRegistration[] } {
31
40
  const declared: string[] = []
32
41
  const registrations: CapturedRegistration[] = []
33
42
  const ctx = {
@@ -43,8 +52,9 @@ function collectRegistrations(): { declared: string[]; registrations: CapturedRe
43
52
  for (const step of steps) { void step }
44
53
  return () => {}
45
54
  },
46
- register: (options: Record<string, unknown>, component: unknown) => {
47
- registrations.push({ slot: String(options['name']), options, component })
55
+ register: (record: Record<string, unknown>, component: unknown) => {
56
+ if (options.registerThrows === true) throw new Error(`unknown slot ${String(record['name'])}`)
57
+ registrations.push({ slot: String(record['name']), options: record, component })
48
58
  return () => {}
49
59
  },
50
60
  },
@@ -58,12 +68,16 @@ describe('settings-seat contract (standalone settings.section only)', () => {
58
68
  expect(inject).toEqual(['slots', 'locale', 'settingsScope'])
59
69
  })
60
70
 
61
- it('injects exactly the settings.section seat plus the shell.overlay float', () => {
71
+ it('injects exactly the settings.section seat and nothing else', () => {
62
72
  const { declared, registrations } = collectRegistrations()
63
- expect(declared).toEqual(['settings.section', 'shell.overlay'])
64
- expect(registrations).toHaveLength(2)
73
+ expect(declared).toEqual(['settings.section'])
74
+ expect(registrations).toHaveLength(1)
65
75
  expect(registrations[0]!.slot).toBe('settings.section')
66
- expect(registrations[1]!.slot).toBe('shell.overlay')
76
+ })
77
+
78
+ it('no longer claims a shell.overlay float (the review panel it served is gone)', () => {
79
+ const { declared } = collectRegistrations()
80
+ expect(declared).not.toContain('shell.overlay')
67
81
  })
68
82
 
69
83
  it('pins the section identity (id/order/label/locale) and the panel component', () => {
@@ -84,4 +98,21 @@ describe('settings-seat contract (standalone settings.section only)', () => {
84
98
  expect(declared).not.toContain('settings.plugins.tab')
85
99
  expect(declared).not.toContain('settings.plugin.item')
86
100
  })
101
+
102
+ it('survives an older host that does not declare the seat, warning instead of crashing', () => {
103
+ // Cross-version tolerance: a host without this seat rejects the
104
+ // registration at the slot boundary. apply() must swallow that and warn —
105
+ // the historical failure mode was apply() bailing out, which silently took
106
+ // EVERY settings entry down with it.
107
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
108
+ try {
109
+ const { declared } = collectRegistrations({ registerThrows: true })
110
+ // It tried the seat (so the degradation is "loud", not a silent no-op)…
111
+ expect(declared).toEqual(['settings.section'])
112
+ // …and the rejection never escaped apply().
113
+ expect(warn).toHaveBeenCalled()
114
+ } finally {
115
+ warn.mockRestore()
116
+ }
117
+ })
87
118
  })