dsh-context-compression-improved 0.1.1 → 0.2.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.
Files changed (148) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/workflows/ci.yml +39 -0
  3. package/CHANGELOG.ja.md +39 -0
  4. package/CHANGELOG.ko.md +39 -0
  5. package/CHANGELOG.md +135 -0
  6. package/CHANGELOG.zh.md +39 -0
  7. package/CONTRIBUTING.md +22 -0
  8. package/README.ja.md +104 -0
  9. package/README.ko.md +103 -0
  10. package/README.md +89 -12
  11. package/README.zh.md +87 -12
  12. package/SECURITY.md +18 -0
  13. package/THIRD_PARTY_NOTICES.md +7 -31
  14. package/docs/installation.ja.md +76 -0
  15. package/docs/installation.ko.md +76 -0
  16. package/docs/installation.md +76 -0
  17. package/docs/installation.zh.md +76 -0
  18. package/docs/repair-log.md +582 -0
  19. package/eslint.config.js +30 -0
  20. package/package.json +84 -81
  21. package/packages/selector/LICENSE +21 -0
  22. package/packages/selector/README.md +26 -0
  23. package/packages/selector/README.zh.md +26 -0
  24. package/packages/selector/THIRD_PARTY_NOTICES.md +38 -0
  25. package/packages/selector/docs/history-tool-call-working-set-spec.md +112 -0
  26. package/packages/selector/docs/native-tool-result-selector-spec.md +34 -0
  27. package/packages/selector/docs/subagent-cache-reuse-spec.md +46 -0
  28. package/packages/selector/lib/style.css +308 -0
  29. package/packages/selector/package.json +115 -0
  30. package/{screenshots.json → packages/selector/screenshots.json} +6 -6
  31. package/packages/selector/src/client/CompressionProfileControls.tsx +229 -0
  32. package/packages/selector/src/client/CompressionProfileSelector.module.css +170 -0
  33. package/packages/selector/src/client/CompressionProfileSelector.tsx +79 -0
  34. package/packages/selector/src/client/CustomPolicyEditor.tsx +216 -0
  35. package/packages/selector/src/client/EstimatorControls.tsx +281 -0
  36. package/packages/selector/src/client/decode.ts +49 -0
  37. package/packages/selector/src/client/index.ts +111 -0
  38. package/packages/selector/src/client/locales.ts +198 -0
  39. package/packages/selector/src/client/preset-options.ts +70 -0
  40. package/packages/selector/src/client/settings-section.tsx +126 -0
  41. package/packages/selector/src/css-modules.d.ts +6 -0
  42. package/packages/selector/src/deepseek-v4-tokenizer.ts +210 -0
  43. package/packages/selector/src/estimator-catalog.ts +104 -0
  44. package/packages/selector/src/index.ts +327 -0
  45. package/packages/selector/src/invariant.ts +113 -0
  46. package/packages/selector/src/preset-overlay.ts +567 -0
  47. package/packages/selector/src/profiles.ts +342 -0
  48. package/packages/selector/src/pruner/content.ts +188 -0
  49. package/packages/selector/src/pruner/session.ts +94 -0
  50. package/packages/selector/src/pruner/state.ts +43 -0
  51. package/packages/selector/src/pruner/tuning.ts +23 -0
  52. package/packages/selector/src/pruner/types.ts +60 -0
  53. package/packages/selector/src/pruner.ts +2144 -0
  54. package/packages/selector/src/runtime/adaptive-cost.ts +194 -0
  55. package/packages/selector/src/runtime/audit.ts +215 -0
  56. package/packages/selector/src/runtime/config.ts +613 -0
  57. package/packages/selector/src/runtime/custom-policy.ts +278 -0
  58. package/packages/selector/src/runtime/deepseek-official-pricing.ts +298 -0
  59. package/packages/selector/src/runtime/deepseek-v4-vision-tokens.ts +254 -0
  60. package/packages/selector/src/runtime/measurement.ts +403 -0
  61. package/packages/selector/src/runtime/reducers.ts +656 -0
  62. package/packages/selector/src/runtime/retrieve.ts +457 -0
  63. package/packages/selector/src/runtime/session-events.ts +17 -0
  64. package/packages/selector/src/runtime/tail-trim.ts +166 -0
  65. package/packages/selector/src/runtime/token-count.ts +72 -0
  66. package/packages/selector/src/runtime/tokenpilot/dedup.ts +81 -0
  67. package/packages/selector/src/runtime/tokenpilot/estimator.ts +183 -0
  68. package/packages/selector/src/runtime/tokenpilot/locator.ts +128 -0
  69. package/packages/selector/src/runtime/tokenpilot/read-state.ts +77 -0
  70. package/packages/selector/src/runtime/types.ts +309 -0
  71. package/packages/selector/src/runtime/value.ts +48 -0
  72. package/packages/selector/tests/auto-compact.client.spec.tsx +226 -0
  73. package/packages/selector/tests/built/client-artifact.spec.ts +51 -0
  74. package/packages/selector/tests/cache-prefix-audit.spec.ts +123 -0
  75. package/packages/selector/tests/code-skeleton.client.spec.ts +88 -0
  76. package/packages/selector/tests/custom-contract.client.spec.ts +202 -0
  77. package/packages/selector/tests/estimator-catalog.spec.ts +70 -0
  78. package/packages/selector/tests/estimator-channel.client.spec.tsx +247 -0
  79. package/packages/selector/tests/estimator-route-registration.host.spec.ts +176 -0
  80. package/packages/selector/tests/host-preset-overlay.host.spec.ts +204 -0
  81. package/packages/selector/tests/preset-options-write.client.spec.ts +181 -0
  82. package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +196 -0
  83. package/packages/selector/tests/preset-overlay.host.spec.ts +243 -0
  84. package/packages/selector/tests/profiles.client.spec.tsx +434 -0
  85. package/packages/selector/tests/public/package-contract.client.spec.ts +33 -0
  86. package/packages/selector/tests/runtime/adaptive-cost.spec.ts +167 -0
  87. package/packages/selector/tests/runtime/audit.spec.ts +129 -0
  88. package/packages/selector/tests/runtime/auto-compact-config.spec.ts +523 -0
  89. package/packages/selector/tests/runtime/code-skeleton.spec.ts +141 -0
  90. package/packages/selector/tests/runtime/deepseek-official-pricing.spec.ts +186 -0
  91. package/packages/selector/tests/runtime/deepseek-v4-tokenizer.spec.ts +122 -0
  92. package/packages/selector/tests/runtime/deepseek-v4-vision-tokens.spec.ts +122 -0
  93. package/packages/selector/tests/runtime/fixtures/profile-baseline.json +273 -0
  94. package/packages/selector/tests/runtime/fixtures/tokenizer-golden.json +106 -0
  95. package/packages/selector/tests/runtime/fixtures/vision-golden.json +459 -0
  96. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +2531 -0
  97. package/packages/selector/tests/runtime/session-events.spec.ts +27 -0
  98. package/packages/selector/tests/runtime/tokenizer-golden.spec.ts +53 -0
  99. package/packages/selector/tests/runtime/tokenpilot/dedup.spec.ts +52 -0
  100. package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +56 -0
  101. package/packages/selector/tests/runtime/tokenpilot/locator.spec.ts +76 -0
  102. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +100 -0
  103. package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +58 -0
  104. package/packages/selector/tests/runtime/value.spec.ts +23 -0
  105. package/packages/selector/tests/standing-generation.host.spec.ts +631 -0
  106. package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +250 -0
  107. package/packages/selector/tests/support/cache-prefix-audit.ts +105 -0
  108. package/packages/selector/tests/support/mock-adapter.ts +37 -0
  109. package/packages/selector/tests/support/ui-primitives.tsx +34 -0
  110. package/packages/selector/tsconfig.json +11 -0
  111. package/packages/selector/tsdown.client.config.ts +102 -0
  112. package/packages/selector/tsdown.config.ts +20 -0
  113. package/pnpm-workspace.yaml +19 -0
  114. package/scripts/capture-profile-baseline.ts +80 -0
  115. package/scripts/generate-tokenizer-fixtures.py +81 -0
  116. package/scripts/generate-vision-fixtures.py +208 -0
  117. package/scripts/packed-components-smoke.ts +713 -0
  118. package/scripts/packed-install-e2e.ts +1072 -0
  119. package/scripts/verify-release.ts +300 -0
  120. package/tests/TEST_INVENTORY.md +42 -0
  121. package/tsconfig.base.json +18 -0
  122. package/tsconfig.json +7 -0
  123. package/tsconfig.scripts.json +13 -0
  124. package/tsconfig.tests.json +15 -0
  125. package/vitest.built.config.ts +9 -0
  126. package/vitest.config.ts +43 -0
  127. /package/{assets → packages/selector/assets}/deepseek-v4/LICENSE.DeepSeek-V4-Pro.txt +0 -0
  128. /package/{assets → packages/selector/assets}/deepseek-v4/manifest.json +0 -0
  129. /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer.json +0 -0
  130. /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer_config.json +0 -0
  131. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/LICENSE.DeepSeek-V4-Flash-Vision-Exp.txt +0 -0
  132. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/manifest.json +0 -0
  133. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer.json +0 -0
  134. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer_config.json +0 -0
  135. /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-profiles.jpg +0 -0
  136. /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-settings.png +0 -0
  137. /package/{cordis.patch.yml → packages/selector/cordis.patch.yml} +0 -0
  138. /package/{dsh.plugin.json → packages/selector/dsh.plugin.json} +0 -0
  139. /package/{lib → packages/selector/lib}/client.d.ts +0 -0
  140. /package/{lib → packages/selector/lib}/client.js +0 -0
  141. /package/{lib → packages/selector/lib}/config.js +0 -0
  142. /package/{lib → packages/selector/lib}/index.d.ts +0 -0
  143. /package/{lib → packages/selector/lib}/index.js +0 -0
  144. /package/{lib → packages/selector/lib}/invariant.d.ts +0 -0
  145. /package/{lib → packages/selector/lib}/invariant.js +0 -0
  146. /package/{lib → packages/selector/lib}/pruner.d.ts +0 -0
  147. /package/{lib → packages/selector/lib}/pruner.js +0 -0
  148. /package/{lib → packages/selector/lib}/tail-trim.js +0 -0
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ assessReusablePrefix,
4
+ buildSafeCacheAuditRecord,
5
+ fingerprintStablePrefix,
6
+ validateCacheUsage,
7
+ type StablePrefixEnvelope,
8
+ } from './support/cache-prefix-audit.js'
9
+
10
+ const parentEnvelope = (): StablePrefixEnvelope => ({
11
+ provider: 'deepseek',
12
+ model: 'deepseek-v4-flash',
13
+ system: ['stable-system'],
14
+ tools: [
15
+ { name: 'alpha', description: 'first', parameters: { type: 'object' } },
16
+ { name: 'beta', description: 'second', parameters: { type: 'object' } },
17
+ ],
18
+ messages: [
19
+ { role: 'user', content: 'completed parent turn' },
20
+ { role: 'assistant', content: 'completed response' },
21
+ ],
22
+ })
23
+
24
+ describe('parent/child cache-prefix characterization', () => {
25
+ it('accepts an identical fork prefix and computes the same fingerprint', () => {
26
+ const parent = parentEnvelope()
27
+ const child = structuredClone(parent)
28
+ const result = assessReusablePrefix({ mode: 'fork', inheritsParentContext: true, parent, child })
29
+
30
+ expect(result).toEqual({ eligible: true, reason: 'identical-fork-prefix' })
31
+ expect(fingerprintStablePrefix(parent)).toBe(fingerprintStablePrefix(child))
32
+ expect(fingerprintStablePrefix(parent)).toMatch(/^[a-f0-9]{64}$/u)
33
+ })
34
+
35
+ it('rejects spawn because it does not inherit parent messages', () => {
36
+ const parent = parentEnvelope()
37
+ const result = assessReusablePrefix({
38
+ mode: 'spawn',
39
+ inheritsParentContext: false,
40
+ parent,
41
+ child: { ...structuredClone(parent), messages: [] },
42
+ })
43
+
44
+ expect(result).toEqual({ eligible: false, reason: 'spawn-does-not-inherit' })
45
+ })
46
+
47
+ it.each([
48
+ ['system', (child: StablePrefixEnvelope) => { child.system = ['different-persona'] }],
49
+ ['tools', (child: StablePrefixEnvelope) => { child.tools.reverse() }],
50
+ ['provider', (child: StablePrefixEnvelope) => { child.provider = 'another-provider' }],
51
+ ['model', (child: StablePrefixEnvelope) => { child.model = 'another-model' }],
52
+ ['messages', (child: StablePrefixEnvelope) => { child.messages[0] = { role: 'user', content: 'different-history' } }],
53
+ ])('rejects a %s mismatch', (_field, mutate) => {
54
+ const parent = parentEnvelope()
55
+ const child = structuredClone(parent)
56
+ mutate(child)
57
+
58
+ expect(assessReusablePrefix({ mode: 'fork', inheritsParentContext: true, parent, child })).toEqual({
59
+ eligible: false,
60
+ reason: 'stable-prefix-mismatch',
61
+ })
62
+ })
63
+
64
+ it('emits only allowlisted evidence fields', () => {
65
+ const secret = 'sk-do-not-record-this'
66
+ const toolResult = 'private tool result'
67
+ const record = buildSafeCacheAuditRecord({
68
+ fingerprint: fingerprintStablePrefix(parentEnvelope()),
69
+ estimatedSharedPrefixTokens: 128,
70
+ parentSessionId: 'parent-id',
71
+ childSessionId: 'child-id',
72
+ mode: 'fork',
73
+ eligible: true,
74
+ reason: 'identical-fork-prefix',
75
+ cacheReadTokens: 96,
76
+ cacheMissTokens: 32,
77
+ observedPromptTokens: 128,
78
+ })
79
+ const serialized = JSON.stringify(record)
80
+
81
+ expect(Object.keys(record).sort()).toEqual([
82
+ 'auditVersion',
83
+ 'cacheMissTokens',
84
+ 'cacheReadTokens',
85
+ 'childSessionId',
86
+ 'confirmationStatus',
87
+ 'eligible',
88
+ 'estimatedSharedPrefixTokens',
89
+ 'fingerprint',
90
+ 'mode',
91
+ 'observedPromptTokens',
92
+ 'parentSessionId',
93
+ 'reason',
94
+ ])
95
+ expect(serialized).not.toContain(secret)
96
+ expect(serialized).not.toContain(toolResult)
97
+ expect(record.confirmationStatus).toBe('confirmed')
98
+ })
99
+
100
+ it('validates official cache usage arithmetic', () => {
101
+ expect(validateCacheUsage({ cacheReadTokens: 96, cacheMissTokens: 32, observedPromptTokens: 128 })).toBe(true)
102
+ expect(validateCacheUsage({ cacheReadTokens: 95, cacheMissTokens: 32, observedPromptTokens: 128 })).toBe(false)
103
+ })
104
+
105
+ it('marks missing official cache fields as unconfirmed without inventing zeros', () => {
106
+ const record = buildSafeCacheAuditRecord({
107
+ fingerprint: fingerprintStablePrefix(parentEnvelope()),
108
+ estimatedSharedPrefixTokens: 128,
109
+ parentSessionId: 'parent-id',
110
+ childSessionId: 'child-id',
111
+ mode: 'fork',
112
+ eligible: true,
113
+ reason: 'identical-fork-prefix',
114
+ })
115
+
116
+ expect(record).toMatchObject({
117
+ cacheReadTokens: null,
118
+ cacheMissTokens: null,
119
+ observedPromptTokens: null,
120
+ confirmationStatus: 'unconfirmed',
121
+ })
122
+ })
123
+ })
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { ClientContext, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
3
+ import { DEFAULT_CUSTOM_COMPRESSION_POLICY } from '../src/profiles.ts'
4
+ import { apply } from '../src/client/index.ts'
5
+ import type {
6
+ CompressionSelectorInjected,
7
+ ContextCompressionSettings,
8
+ } from '../src/client/CompressionProfileSelector.tsx'
9
+
10
+ vi.mock('@deepseek-ai/dsh-client-ui-primitives', () => ({
11
+ IconChevronDownOutline14: () => null,
12
+ Menu: () => null,
13
+ }))
14
+
15
+ describe('codeSkeleton confirm-on-write client contract', () => {
16
+ it('resolves only when the host confirms the toggled value and rejects silent drops', async () => {
17
+ let revision = 3
18
+ let commit = false
19
+ let flipEnabled = false
20
+ let value: ContextCompressionSettings = {
21
+ profile: 'balanced',
22
+ custom: structuredClone(DEFAULT_CUSTOM_COMPRESSION_POLICY),
23
+ autoCompact: { thresholdPercent: 80 },
24
+ codeSkeleton: { enabled: false },
25
+ }
26
+ const snapshot = (): SettingsScopeSnapshot<ContextCompressionSettings> => ({
27
+ status: 'ready',
28
+ value,
29
+ base: undefined,
30
+ user: undefined,
31
+ revision,
32
+ writable: true,
33
+ mode: 'host',
34
+ })
35
+ const scope = {
36
+ getSnapshot: snapshot,
37
+ subscribe: vi.fn(() => () => {}),
38
+ set: vi.fn(async (field: string, next: unknown) => {
39
+ if (!commit) return
40
+ revision += 1
41
+ if (field === 'codeSkeleton' && flipEnabled) {
42
+ const accepted = structuredClone(next as ContextCompressionSettings['codeSkeleton'])
43
+ accepted.enabled = !accepted.enabled
44
+ value = { ...value, codeSkeleton: accepted }
45
+ return
46
+ }
47
+ value = { ...value, [field]: next }
48
+ }),
49
+ unset: vi.fn(async () => {}),
50
+ }
51
+ let injected: (() => CompressionSelectorInjected) | undefined
52
+ const ctx = {
53
+ effect: (install: () => unknown) => { install() },
54
+ locale: { register: vi.fn(() => () => {}) },
55
+ settingsScope: { bind: vi.fn(() => scope) },
56
+ slots: {
57
+ inject: (_slot: string, install: () => unknown) => { install() },
58
+ register: (registration: { inject: () => CompressionSelectorInjected }) => {
59
+ injected = registration.inject
60
+ return () => {}
61
+ },
62
+ },
63
+ } as unknown as ClientContext
64
+
65
+ apply(ctx)
66
+ const actions = injected?.()
67
+ expect(actions).toBeDefined()
68
+ if (actions === undefined) throw new Error('selector injection was not registered')
69
+
70
+ // A silent host drop leaves the revision untouched: the write must reject.
71
+ await expect(actions.saveCodeSkeleton(true)).rejects.toThrow('were not saved')
72
+
73
+ // A committed write resolves and persists the exact section payload.
74
+ commit = true
75
+ await expect(actions.saveCodeSkeleton(true)).resolves.toBeUndefined()
76
+ expect(scope.set).toHaveBeenLastCalledWith('codeSkeleton', { enabled: true })
77
+ expect(value.codeSkeleton).toEqual({ enabled: true })
78
+
79
+ // A host that flips the toggled bit fails the acceptance check.
80
+ flipEnabled = true
81
+ await expect(actions.saveCodeSkeleton(false)).rejects.toThrow('were not saved')
82
+
83
+ flipEnabled = false
84
+ await expect(actions.saveCodeSkeleton(false)).resolves.toBeUndefined()
85
+ expect(scope.set).toHaveBeenLastCalledWith('codeSkeleton', { enabled: false })
86
+ expect(value.codeSkeleton).toEqual({ enabled: false })
87
+ })
88
+ })
@@ -0,0 +1,202 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { ClientContext, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
3
+ import {
4
+ DEFAULT_CUSTOM_COMPRESSION_POLICY,
5
+ isCustomCompressionPolicy,
6
+ } from '../src/profiles.ts'
7
+ import { apply } from '../src/client/index.ts'
8
+ import { decodeSettings } from '../src/client/decode.ts'
9
+ import type {
10
+ CompressionSelectorInjected,
11
+ ContextCompressionSettings,
12
+ } from '../src/client/CompressionProfileSelector.tsx'
13
+
14
+ vi.mock('@deepseek-ai/dsh-client-ui-primitives', () => ({
15
+ IconChevronDownOutline14: () => null,
16
+ Menu: () => null,
17
+ }))
18
+
19
+ const CUSTOM = {
20
+ version: 1,
21
+ unit: 'tokens',
22
+ fresh: { enabled: true, trigger: 4_096, target: 2_048 },
23
+ aggregate: { enabled: true, trigger: 16_384, target: 8_192 },
24
+ history: {
25
+ enabled: true,
26
+ trigger: 32_768,
27
+ keepRecentTurns: 2,
28
+ keepRecent: 16_384,
29
+ minReclaim: 8_192,
30
+ },
31
+ prefixPolicy: 'pressure-break',
32
+ } as const satisfies ContextCompressionSettings['custom']
33
+
34
+ describe('context compression browser contract', () => {
35
+ it('accepts legacy v1 and defaults new edits to v3 with TailTrim disabled', () => {
36
+ expect(isCustomCompressionPolicy(CUSTOM)).toBe(true)
37
+ expect(DEFAULT_CUSTOM_COMPRESSION_POLICY).toMatchObject({
38
+ version: 3,
39
+ tailTrim: { enabled: false, trigger: 700_000 },
40
+ })
41
+ expect(isCustomCompressionPolicy(DEFAULT_CUSTOM_COMPRESSION_POLICY)).toBe(true)
42
+ })
43
+
44
+ it('fails the browser narrowing for unknown or invalid Custom fields', () => {
45
+ expect(isCustomCompressionPolicy({
46
+ ...structuredClone(CUSTOM),
47
+ tailTrim: { enabled: true },
48
+ })).toBe(false)
49
+ expect(isCustomCompressionPolicy({
50
+ ...structuredClone(CUSTOM),
51
+ fresh: { enabled: true, trigger: 100, target: 100 },
52
+ })).toBe(false)
53
+ for (const trigger of [0, -1]) {
54
+ expect(isCustomCompressionPolicy({
55
+ ...structuredClone(DEFAULT_CUSTOM_COMPRESSION_POLICY),
56
+ tailTrim: { enabled: true, trigger },
57
+ })).toBe(false)
58
+ }
59
+ expect(isCustomCompressionPolicy({
60
+ ...structuredClone(DEFAULT_CUSTOM_COMPRESSION_POLICY),
61
+ unit: 'context-percent',
62
+ tailTrim: { enabled: true, trigger: 101 },
63
+ })).toBe(false)
64
+ })
65
+
66
+ it('decodes legacy settings to the default threshold and rejects malformed sections', async () => {
67
+ // Legacy documents stored before autoCompact existed inherit 80%.
68
+ const legacy = decodeSettings({ profile: 'balanced', custom: structuredClone(CUSTOM) })
69
+ expect(legacy).toMatchObject({ autoCompact: { thresholdPercent: 80 } })
70
+ // A present-but-invalid threshold fails the whole decode.
71
+ expect(decodeSettings({
72
+ profile: 'balanced',
73
+ custom: structuredClone(CUSTOM),
74
+ autoCompact: { thresholdPercent: 91 },
75
+ })).toBeUndefined()
76
+ })
77
+
78
+ it('reports a recovered no-op write only on the selector boundary', async () => {
79
+ let revision = 7
80
+ let commit = false
81
+ let rewriteCustom = false
82
+ let rewriteReset = false
83
+ let dropTailTrim = false
84
+ let downgradeVersion = false
85
+ let value: ContextCompressionSettings = {
86
+ profile: 'balanced',
87
+ custom: structuredClone(CUSTOM),
88
+ autoCompact: { thresholdPercent: 80 },
89
+ codeSkeleton: { enabled: false },
90
+ }
91
+ const snapshot = (): SettingsScopeSnapshot<ContextCompressionSettings> => ({
92
+ status: 'ready',
93
+ value,
94
+ base: undefined,
95
+ user: undefined,
96
+ revision,
97
+ writable: true,
98
+ mode: 'host',
99
+ })
100
+ const scope = {
101
+ getSnapshot: snapshot,
102
+ subscribe: vi.fn(() => () => {}),
103
+ set: vi.fn(async (field: string, next: unknown) => {
104
+ if (!commit) return
105
+ revision += 1
106
+ if (field === 'custom' && rewriteCustom) {
107
+ const accepted = structuredClone(next as ContextCompressionSettings['custom'])
108
+ accepted.fresh.trigger += 1
109
+ value = { ...value, custom: accepted }
110
+ return
111
+ }
112
+ if (field === 'custom' && rewriteReset) {
113
+ const accepted = structuredClone(next as ContextCompressionSettings['custom'])
114
+ accepted.fresh.trigger += 1
115
+ value = { ...value, custom: accepted }
116
+ return
117
+ }
118
+ if (field === 'custom' && dropTailTrim) {
119
+ const accepted = structuredClone(next as ContextCompressionSettings['custom'])
120
+ if (accepted.version === 3) {
121
+ accepted.tailTrim = { enabled: false, trigger: 700_000 }
122
+ }
123
+ value = { ...value, custom: accepted }
124
+ return
125
+ }
126
+ if (field === 'custom' && downgradeVersion) {
127
+ value = { ...value, custom: structuredClone(CUSTOM) }
128
+ return
129
+ }
130
+ value = { ...value, [field]: next }
131
+ }),
132
+ unset: vi.fn(async () => {
133
+ if (!commit) return
134
+ revision += 1
135
+ const custom: ContextCompressionSettings['custom'] = structuredClone(CUSTOM)
136
+ if (rewriteReset) {
137
+ custom.fresh.trigger = 8_192
138
+ custom.fresh.target = 4_096
139
+ }
140
+ value = { ...value, custom }
141
+ }),
142
+ }
143
+ let injected: (() => CompressionSelectorInjected) | undefined
144
+ const ctx = {
145
+ effect: (install: () => unknown) => { install() },
146
+ locale: { register: vi.fn(() => () => {}) },
147
+ settingsScope: { bind: vi.fn(() => scope) },
148
+ slots: {
149
+ inject: (_slot: string, install: () => unknown) => { install() },
150
+ register: (registration: { inject: () => CompressionSelectorInjected }) => {
151
+ injected = registration.inject
152
+ return () => {}
153
+ },
154
+ },
155
+ } as unknown as ClientContext
156
+
157
+ apply(ctx)
158
+ const actions = injected?.()
159
+ expect(actions).toBeDefined()
160
+ if (actions === undefined) throw new Error('selector injection was not registered')
161
+ await expect(actions.select('native')).rejects.toThrow('were not saved')
162
+
163
+ commit = true
164
+ await expect(actions.select('native')).resolves.toBeUndefined()
165
+ expect(value.profile).toBe('native')
166
+
167
+ const requested: ContextCompressionSettings['custom'] = structuredClone(CUSTOM)
168
+ requested.fresh.trigger = 8_192
169
+ requested.fresh.target = 4_096
170
+ rewriteCustom = true
171
+ await expect(actions.saveCustom(requested)).rejects.toThrow('were not saved')
172
+
173
+ rewriteCustom = false
174
+ const requestedV3 = structuredClone(DEFAULT_CUSTOM_COMPRESSION_POLICY)
175
+ if (requestedV3.version !== 3) throw new Error('Custom TailTrim persistence fixture requires policy v3')
176
+ requestedV3.tailTrim = { enabled: true, trigger: 49_152 }
177
+ dropTailTrim = true
178
+ await expect(actions.saveCustom(requestedV3)).rejects.toThrow('were not saved')
179
+
180
+ dropTailTrim = false
181
+ downgradeVersion = true
182
+ await expect(actions.saveCustom(requestedV3)).rejects.toThrow('were not saved')
183
+
184
+ downgradeVersion = false
185
+ rewriteReset = true
186
+ await expect(actions.resetCustom()).rejects.toThrow('were not saved')
187
+
188
+ rewriteReset = false
189
+ await expect(actions.resetCustom()).resolves.toBeUndefined()
190
+ expect(scope.set).toHaveBeenLastCalledWith('custom', DEFAULT_CUSTOM_COMPRESSION_POLICY)
191
+ expect(scope.unset).not.toHaveBeenCalled()
192
+
193
+ // The Auto Compact threshold shares the same confirm-on-write boundary:
194
+ // committed writes resolve, and a silent host drop rejects.
195
+ commit = false
196
+ await expect(actions.saveAutoCompact(73)).rejects.toThrow('were not saved')
197
+ commit = true
198
+ await expect(actions.saveAutoCompact(73)).resolves.toBeUndefined()
199
+ expect(scope.set).toHaveBeenLastCalledWith('autoCompact', { thresholdPercent: 73 })
200
+ expect(value.autoCompact).toEqual({ thresholdPercent: 73 })
201
+ })
202
+ })
@@ -0,0 +1,70 @@
1
+ /** Estimator catalog projection: never throws, selection precedence, inline errors. */
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+ import {
5
+ buildEstimatorCatalog, resolveHostRoute,
6
+ type EstimatorCatalogDeps,
7
+ } from '../src/estimator-catalog.ts'
8
+
9
+ describe('resolveHostRoute', () => {
10
+ it('prefers explicit overrides over the host default selection', () => {
11
+ expect(resolveHostRoute({
12
+ overrideProvider: 'p1',
13
+ overrideModel: 'm1',
14
+ currentSelection: () => ({ provider: 'p2', model: 'm2' }),
15
+ })).toEqual({ provider: 'p1', model: 'm1' })
16
+ })
17
+
18
+ it('falls back to the host default selection when overrides are empty', () => {
19
+ expect(resolveHostRoute({
20
+ overrideProvider: '',
21
+ overrideModel: '',
22
+ currentSelection: () => ({ provider: 'p2', model: 'm2' }),
23
+ })).toEqual({ provider: 'p2', model: 'm2' })
24
+ })
25
+
26
+ it('fills missing override fields from the selection, per field', () => {
27
+ expect(resolveHostRoute({ overrideProvider: 'p1', currentSelection: () => ({ model: 'm2' }) }))
28
+ .toEqual({ provider: 'p1', model: 'm2' })
29
+ })
30
+
31
+ it('reports undefined when neither source yields a complete route', () => {
32
+ expect(resolveHostRoute({ currentSelection: () => ({ provider: 'p' }) })).toBeUndefined()
33
+ expect(resolveHostRoute({})).toBeUndefined()
34
+ })
35
+ })
36
+
37
+ describe('buildEstimatorCatalog', () => {
38
+ it('lists every provider with its models and never throws on one broken group', async () => {
39
+ const llm = {
40
+ listProviders: () => [{ id: 'deepseek', name: 'DeepSeek' }, { id: 'broken', name: 'Broken' }],
41
+ listModels: async (providerId: string) => {
42
+ if (providerId === 'broken') throw new Error('adapter offline')
43
+ return [{ id: 'm1', name: 'Model 1' }]
44
+ },
45
+ }
46
+ const catalog = await buildEstimatorCatalog({ llm })
47
+ expect(catalog.providers).toHaveLength(2)
48
+ expect(catalog.providers[0]).toEqual({ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'm1', name: 'Model 1' }] })
49
+ expect(catalog.providers[1]?.error).toBe('adapter offline')
50
+ })
51
+
52
+ it('uses route discovery when listModels is absent or silent', async () => {
53
+ const llm: NonNullable<EstimatorCatalogDeps['llm']> = {
54
+ listProviders: () => [{ id: 'self-hosted', name: 'Self hosted' }],
55
+ listConfigurableProviders: () => [{ provider: 'self-hosted', settingsNs: 'llm.self-hosted' }],
56
+ discoverModels: async () => [{ id: 'm9' }],
57
+ }
58
+ const catalog = await buildEstimatorCatalog({ llm })
59
+ expect(catalog.providers[0]?.models).toEqual([{ id: 'm9', name: 'm9' }])
60
+ expect(catalog.providers[0]?.error).toBeUndefined()
61
+ })
62
+
63
+ it('returns the host selection even when the llm service is unavailable', async () => {
64
+ const catalog = await buildEstimatorCatalog({
65
+ currentSelection: () => ({ provider: 'p', model: 'm' }),
66
+ })
67
+ expect(catalog.providers).toEqual([])
68
+ expect(catalog.selection).toEqual({ provider: 'p', model: 'm' })
69
+ })
70
+ })