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,247 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { useSyncExternalStore } from 'react'
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import {
7
+ ContextCompressionSettingsSection,
8
+ type CompressionProfileSelectorProps,
9
+ type ContextCompressionSettings,
10
+ } from '../src/client/CompressionProfileSelector.tsx'
11
+ import { en } from '../src/client/locales.ts'
12
+
13
+ afterEach(() => {
14
+ cleanup()
15
+ vi.unstubAllGlobals()
16
+ })
17
+
18
+ interface SnapshotStore<T> {
19
+ readonly getSnapshot: () => T
20
+ readonly subscribe: (listener: () => void) => () => void
21
+ }
22
+
23
+ function createSnapshotStore<T>(initial: T): SnapshotStore<T> {
24
+ const snapshot = structuredClone(initial)
25
+ const listeners = new Set<() => void>()
26
+ return {
27
+ getSnapshot: () => snapshot,
28
+ subscribe(listener) {
29
+ listeners.add(listener)
30
+ return () => { listeners.delete(listener) }
31
+ },
32
+ }
33
+ }
34
+
35
+ function bindSnapshotSelector<T>(store: SnapshotStore<T>) {
36
+ return function useSnapshotSelector<U>(selector: (snapshot: T) => U): U {
37
+ return useSyncExternalStore(
38
+ store.subscribe,
39
+ () => selector(store.getSnapshot()),
40
+ () => selector(store.getSnapshot()),
41
+ )
42
+ }
43
+ }
44
+
45
+ const DEFAULT_CUSTOM = {
46
+ version: 1,
47
+ unit: 'tokens',
48
+ fresh: { enabled: true, trigger: 4_096, target: 2_048 },
49
+ aggregate: { enabled: true, trigger: 16_384, target: 8_192 },
50
+ history: {
51
+ enabled: true,
52
+ trigger: 32_768,
53
+ keepRecentTurns: 2,
54
+ keepRecent: 16_384,
55
+ minReclaim: 8_192,
56
+ },
57
+ prefixPolicy: 'pressure-break',
58
+ } as const satisfies ContextCompressionSettings['custom']
59
+
60
+ const t = (key: string) => en[key as keyof typeof en] ?? key
61
+
62
+ const PROVIDER_LABEL = 'Provider'
63
+ const CHANNEL_LABEL = 'Channel'
64
+ const MODEL_LABEL = 'Model'
65
+ const API_KEY_LABEL = 'API key (write-only, never echoed)'
66
+ const BASE_URL_LABEL = 'Endpoint base URL (/v1)'
67
+
68
+ const CATALOG = {
69
+ ok: true,
70
+ providers: [
71
+ {
72
+ id: 'local-35b',
73
+ name: 'local-35b',
74
+ models: [{ id: 'Qwen3.6-35B-A3B', name: 'Qwen3.6-35B-A3B' }, { id: 'Qwen38-27B', name: 'Qwen38-27B' }],
75
+ },
76
+ { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-flash', name: 'DeepSeek-V41-Flash' }] },
77
+ ],
78
+ selection: { provider: 'deepseek-official', model: 'deepseek-flash' },
79
+ }
80
+
81
+ function mountEstimator(
82
+ presetOptions: ContextCompressionSettings['presetOptions'],
83
+ options: { readonly catalog?: unknown, readonly profile?: ContextCompressionSettings['profile'] } = {},
84
+ ) {
85
+ const state = createSnapshotStore({
86
+ status: 'ready' as const,
87
+ value: {
88
+ profile: options.profile ?? ('tokenpilot-inspired' as const),
89
+ custom: structuredClone(DEFAULT_CUSTOM),
90
+ autoCompact: { thresholdPercent: 80 },
91
+ codeSkeleton: { enabled: false },
92
+ presetOptions: presetOptions ?? {},
93
+ },
94
+ base: undefined,
95
+ user: undefined,
96
+ revision: 0,
97
+ writable: true,
98
+ mode: 'host' as const,
99
+ })
100
+ const sessions = createSnapshotStore({
101
+ ids: ['s1'],
102
+ byId: { s1: { id: 's1', agentPreset: 'standard' } },
103
+ current: 's1',
104
+ })
105
+ const savePresetOptions = vi.fn(() => Promise.resolve())
106
+ if (options.catalog !== undefined) {
107
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({
108
+ ok: true,
109
+ json: () => Promise.resolve(options.catalog),
110
+ })))
111
+ }
112
+ render(<ContextCompressionSettingsSection {...({
113
+ useCompression: bindSnapshotSelector(state),
114
+ useSessions: bindSnapshotSelector(sessions),
115
+ select: vi.fn(() => Promise.resolve()),
116
+ saveCustom: vi.fn(() => Promise.resolve()),
117
+ resetCustom: vi.fn(() => Promise.resolve()),
118
+ saveAutoCompact: vi.fn(() => Promise.resolve()),
119
+ saveCodeSkeleton: vi.fn(() => Promise.resolve()),
120
+ savePresetOptions,
121
+ t,
122
+ } as unknown as CompressionProfileSelectorProps)} />)
123
+ return { savePresetOptions }
124
+ }
125
+
126
+ describe('estimator channel card', () => {
127
+ it('host mode fills comboboxes from the catalog, keeps custom typing, and asks for no API key', async () => {
128
+ const { savePresetOptions } = mountEstimator({ estimatorMode: 'host' }, { catalog: CATALOG })
129
+
130
+ // Provider/model are always comboboxes (dsh-perm-gate receiver pattern):
131
+ // a catalog pick fills the field, and the same input accepts a custom id.
132
+ const provider = screen.getByLabelText<HTMLInputElement>(PROVIDER_LABEL)
133
+ const model = screen.getByLabelText<HTMLInputElement>(MODEL_LABEL)
134
+ await waitFor(() => {
135
+ const providerOptions = document.querySelectorAll('#estimator-provider-options option')
136
+ expect([...providerOptions].map(option => (option as HTMLOptionElement).value))
137
+ .toEqual(['local-35b', 'deepseek-official'])
138
+ expect(provider.getAttribute('list')).toBe('estimator-provider-options')
139
+ expect(model.getAttribute('list')).toBe('estimator-model-options')
140
+ })
141
+ expect(screen.getByText(/Effective route: deepseek-official \/ deepseek-flash/)).not.toBeNull()
142
+ expect(screen.getByText(/no API key is needed/)).not.toBeNull()
143
+
144
+ // No credential input and no base-URL field on this channel.
145
+ expect(screen.queryByLabelText(API_KEY_LABEL)).toBeNull()
146
+ expect(screen.queryByLabelText(BASE_URL_LABEL)).toBeNull()
147
+ expect(document.querySelectorAll('input[type="password"]').length).toBe(0)
148
+
149
+ // A datalist pick fires change without blur: it commits immediately.
150
+ fireEvent.change(provider, { target: { value: 'local-35b' } })
151
+ await waitFor(() => {
152
+ expect(savePresetOptions).toHaveBeenCalledWith({ estimatorProvider: 'local-35b' })
153
+ })
154
+ // Free typing only commits on blur, so mid-edit keystrokes never save.
155
+ fireEvent.change(provider, { target: { value: 'my-custom-group' } })
156
+ expect(savePresetOptions).not.toHaveBeenCalledWith({ estimatorProvider: 'my-custom-group' })
157
+ fireEvent.blur(provider)
158
+ await waitFor(() => {
159
+ expect(savePresetOptions).toHaveBeenCalledWith({ estimatorProvider: 'my-custom-group' })
160
+ })
161
+ })
162
+
163
+ it('offers the catalog models of the chosen provider and commits one', async () => {
164
+ const { savePresetOptions } = mountEstimator(
165
+ { estimatorMode: 'host', estimatorProvider: 'local-35b' },
166
+ { catalog: CATALOG },
167
+ )
168
+ // Query before the catalog lands: once the datalist has options, the
169
+ // label's textContent includes them and getByLabelText's exact match
170
+ // would no longer see a bare "Model".
171
+ const model = screen.getByLabelText<HTMLInputElement>(MODEL_LABEL)
172
+ expect(model.value).toBe('')
173
+ await waitFor(() => {
174
+ const modelOptions = document.querySelectorAll('#estimator-model-options option')
175
+ expect([...modelOptions].map(option => (option as HTMLOptionElement).value))
176
+ .toEqual(['Qwen3.6-35B-A3B', 'Qwen38-27B'])
177
+ })
178
+ expect(screen.getByText(/Effective route: local-35b/)).not.toBeNull()
179
+
180
+ fireEvent.change(model, { target: { value: 'Qwen38-27B' } })
181
+ await waitFor(() => {
182
+ expect(savePresetOptions).toHaveBeenCalledWith({ estimatorModel: 'Qwen38-27B' })
183
+ })
184
+ })
185
+
186
+ it('still accepts custom names — without a credential field — when no catalog is served', async () => {
187
+ vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('no route'))))
188
+ const { savePresetOptions } = mountEstimator({ estimatorMode: 'host' })
189
+
190
+ const provider = screen.getByLabelText<HTMLInputElement>(PROVIDER_LABEL)
191
+ expect(screen.getByLabelText(MODEL_LABEL).tagName).toBe('INPUT')
192
+ expect(document.querySelectorAll('#estimator-provider-options option').length).toBe(0)
193
+ expect(document.querySelectorAll('#estimator-model-options option').length).toBe(0)
194
+ fireEvent.change(provider, { target: { value: 'manual-group' } })
195
+ fireEvent.blur(provider)
196
+ await waitFor(() => {
197
+ expect(savePresetOptions).toHaveBeenCalledWith({ estimatorProvider: 'manual-group' })
198
+ })
199
+ expect(screen.queryByLabelText(API_KEY_LABEL)).toBeNull()
200
+ expect(screen.getByText(/no API key is needed/)).not.toBeNull()
201
+ expect(screen.getByText(/not determined yet/)).not.toBeNull()
202
+ })
203
+
204
+ it('keeps the endpoint and key fields on the direct channel alone', () => {
205
+ mountEstimator({ estimatorMode: 'direct' })
206
+
207
+ expect(screen.getByLabelText(BASE_URL_LABEL)).not.toBeNull()
208
+ expect(screen.getByLabelText(MODEL_LABEL).tagName).toBe('INPUT')
209
+ expect(screen.getByLabelText(API_KEY_LABEL)).not.toBeNull()
210
+ expect(document.querySelectorAll('input[type="password"]').length).toBe(1)
211
+ expect(screen.queryByLabelText(PROVIDER_LABEL)).toBeNull()
212
+ expect(screen.queryByText(/no API key is needed/)).toBeNull()
213
+ })
214
+
215
+ it('renders no channel controls while the estimator is off', () => {
216
+ mountEstimator({})
217
+ expect(screen.queryByLabelText(API_KEY_LABEL)).toBeNull()
218
+ expect(screen.queryByLabelText(BASE_URL_LABEL)).toBeNull()
219
+ expect(screen.queryByLabelText(PROVIDER_LABEL)).toBeNull()
220
+ })
221
+
222
+ // Regression guard for the first real-machine report: the card used to render
223
+ // nothing at all off tokenpilot-inspired, which reads as a missing feature.
224
+ it('keeps the estimator section, and names the unlocking profile, off tokenpilot-inspired', () => {
225
+ mountEstimator({}, { profile: 'balanced' })
226
+
227
+ // The heading and its stable anchor survive, so the panel is still findable
228
+ // where the reader last saw it.
229
+ expect(document.getElementById('context-compression-estimator-title')?.textContent)
230
+ .toBe('Estimator (optional)')
231
+ // The notice names the current profile and the profile that unlocks the card.
232
+ const notice = screen.getByText(/ships only with the TokenPilot-inspired profile/)
233
+ expect(notice.textContent).toContain('“Balanced”')
234
+ expect(notice.textContent).toContain('Select TokenPilot-inspired')
235
+ // Gating semantics are unchanged: no channel control exists here.
236
+ expect(screen.queryByLabelText(CHANNEL_LABEL)).toBeNull()
237
+ expect(screen.queryByLabelText(PROVIDER_LABEL)).toBeNull()
238
+ expect(screen.queryByLabelText(API_KEY_LABEL)).toBeNull()
239
+ expect(document.querySelectorAll('input[list]').length).toBe(0)
240
+ })
241
+
242
+ it('drops the inactive notice once tokenpilot-inspired owns the selector', () => {
243
+ mountEstimator({})
244
+ expect(screen.queryByText(/ships only with the TokenPilot-inspired profile/)).toBeNull()
245
+ expect(screen.getByLabelText(CHANNEL_LABEL)).not.toBeNull()
246
+ })
247
+ })
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Host-side guard for the estimator catalog route.
3
+ *
4
+ * The defect this pins is a registration that never happens: the injection gate
5
+ * asked for more services than the handler needs, and an unsatisfied
6
+ * `ctx.inject` callback fails silently, so the plugin simply had no HTTP API
7
+ * and every request fell through to the host 404. These cases assert the two
8
+ * properties that failure violated — the route appears whenever `webServer` is
9
+ * usable, in either arrival order, and its absence is never silent.
10
+ */
11
+
12
+ import { Context } from '@deepseek-ai/cordis'
13
+ import { afterEach, describe, expect, it, vi } from 'vitest'
14
+ import { apply } from '../src/index.ts'
15
+
16
+ const CATALOG_ROUTE = '/api/dsh-context-compression-improved/estimator-catalog'
17
+ const LEGACY_ROUTE = '/endpoint/dsh-context-compression-improved/estimator-catalog'
18
+
19
+ interface RegisteredRoute {
20
+ kind: string
21
+ path: string
22
+ handler: (req: unknown, res: unknown) => void
23
+ }
24
+
25
+ interface FakeResponse {
26
+ status?: number
27
+ body?: string
28
+ }
29
+
30
+ /** The state `register` reads off its own `this`, mirroring the host service. */
31
+ interface WebServerStub {
32
+ tables: { exact: Map<string, RegisteredRoute> }
33
+ }
34
+
35
+ let ctx: Context | undefined
36
+
37
+ afterEach(async () => {
38
+ await ctx?.fiber.dispose()
39
+ ctx = undefined
40
+ })
41
+
42
+ /** Settle one macrotask so a deferred activation callback can run. */
43
+ const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 20))
44
+
45
+ /**
46
+ * Mount a host web server stand-in that records routes and reproduces the real
47
+ * host's duplicate-`(kind, path)` contract, so a double registration is a
48
+ * failure rather than a silent overwrite.
49
+ */
50
+ async function mountWebServer(runtime: Context, routes: RegisteredRoute[]): Promise<void> {
51
+ await runtime.plugin({
52
+ name: 'fake-webserver',
53
+ apply(webCtx) {
54
+ webCtx.provide('webServer', {
55
+ // Mirrors dsh-host-webserver: `register` reads its route table off
56
+ // `this`. A caller that detaches the method and invokes it standalone
57
+ // makes `this` the wrapper and fails here, exactly as the host does --
58
+ // which is the defect this spec exists to catch.
59
+ tables: { exact: new Map<string, RegisteredRoute>() },
60
+ register(this: WebServerStub, route: RegisteredRoute) {
61
+ const table = this.tables.exact
62
+ if (table.has(route.path)) {
63
+ throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
64
+ }
65
+ table.set(route.path, route)
66
+ routes.push(route)
67
+ return () => {}
68
+ },
69
+ })
70
+ },
71
+ })
72
+ }
73
+
74
+ /** Mount the estimator-side services the handler only enriches its payload with. */
75
+ async function mountEstimatorServices(runtime: Context): Promise<void> {
76
+ await runtime.plugin({
77
+ name: 'fake-estimator-services',
78
+ apply(estimatorCtx) {
79
+ estimatorCtx.provide('llm', { listProviders: () => [] })
80
+ estimatorCtx.provide('agentDefaultModel', { currentSelection: () => undefined })
81
+ },
82
+ })
83
+ }
84
+
85
+ /** Drive one registered route through a stand-in response. */
86
+ async function invoke(route: RegisteredRoute): Promise<FakeResponse> {
87
+ const captured: FakeResponse = {}
88
+ const res = {
89
+ writeHead(code: number) { captured.status = code },
90
+ end(body?: string) { if (body !== undefined) captured.body = body },
91
+ }
92
+ route.handler({ method: 'GET' }, res)
93
+ await settle()
94
+ return captured
95
+ }
96
+
97
+ describe('estimator catalog route registration', () => {
98
+ it('registers both route prefixes with only webServer present', async () => {
99
+ const routes: RegisteredRoute[] = []
100
+ const runtime = new Context()
101
+ ctx = runtime
102
+ await mountWebServer(runtime, routes)
103
+
104
+ apply(runtime, { estimatorCatalogRoute: true })
105
+ await settle()
106
+
107
+ expect(routes.map(route => route.path)).toEqual([LEGACY_ROUTE, CATALOG_ROUTE])
108
+ expect(routes.every(route => route.kind === 'exact')).toBe(true)
109
+ })
110
+
111
+ it('registers the route when webServer activates after the plugin applied', async () => {
112
+ const routes: RegisteredRoute[] = []
113
+ const runtime = new Context()
114
+ ctx = runtime
115
+
116
+ apply(runtime, { estimatorCatalogRoute: true })
117
+ await settle()
118
+ expect(routes).toHaveLength(0)
119
+
120
+ await mountWebServer(runtime, routes)
121
+ await settle()
122
+
123
+ expect(routes.map(route => route.path)).toEqual([LEGACY_ROUTE, CATALOG_ROUTE])
124
+ })
125
+
126
+ it('registers nothing twice when the estimator services arrive later', async () => {
127
+ const routes: RegisteredRoute[] = []
128
+ const runtime = new Context()
129
+ ctx = runtime
130
+ await mountWebServer(runtime, routes)
131
+
132
+ apply(runtime, { estimatorCatalogRoute: true })
133
+ await settle()
134
+ const afterRegistration = routes.length
135
+
136
+ await mountEstimatorServices(runtime)
137
+ await settle()
138
+
139
+ expect(routes).toHaveLength(afterRegistration)
140
+ })
141
+
142
+ it('keeps the rest of the plugin alive and warns when webServer never arrives', async () => {
143
+ const runtime = new Context()
144
+ ctx = runtime
145
+ // The diagnostic goes to `console`: the host's cordis logger surfaces no
146
+ // plugin output at all, so a lifecycle line published there is invisible.
147
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
148
+
149
+ try {
150
+ expect(() => apply(runtime, { estimatorCatalogRoute: true })).not.toThrow()
151
+ await settle()
152
+
153
+ const messages = warn.mock.calls.map(([message]) => String(message))
154
+ expect(messages.some(message => message.includes('estimator catalog route pending'))).toBe(true)
155
+ } finally {
156
+ warn.mockRestore()
157
+ }
158
+ })
159
+
160
+ it('answers 200 without the estimator services, which only enrich the payload', async () => {
161
+ const routes: RegisteredRoute[] = []
162
+ const runtime = new Context()
163
+ ctx = runtime
164
+ await mountWebServer(runtime, routes)
165
+
166
+ apply(runtime, { estimatorCatalogRoute: true })
167
+ await settle()
168
+
169
+ const route = routes.find(candidate => candidate.path === CATALOG_ROUTE)
170
+ expect(route).toBeDefined()
171
+ const response = await invoke(route as RegisteredRoute)
172
+
173
+ expect(response.status).toBe(200)
174
+ expect(JSON.parse(String(response.body))).toMatchObject({ ok: true, providers: [] })
175
+ })
176
+ })
@@ -0,0 +1,204 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { Context, Service } from '@deepseek-ai/cordis'
5
+ import type { AgentPreset } from '@deepseek-ai/dsh-agent-presets'
6
+ import {
7
+ SettingsProvider,
8
+ settingsNamespace,
9
+ type SettingsNamespace,
10
+ } from '@deepseek-ai/dsh-settings'
11
+ import { afterEach, describe, expect, it } from 'vitest'
12
+ import { apply } from '../src/index.ts'
13
+ import type { OverlayableAgentPresets } from '../src/preset-overlay.ts'
14
+
15
+ let root: string | undefined
16
+ let ctx: Context | undefined
17
+
18
+ afterEach(async () => {
19
+ await ctx?.fiber.dispose()
20
+ ctx = undefined
21
+ if (root !== undefined) await rm(root, { recursive: true, force: true })
22
+ root = undefined
23
+ })
24
+
25
+ class MemorySettings extends SettingsProvider {
26
+ readonly writable = true
27
+ private readonly stored: Record<string, unknown> = {}
28
+
29
+ protected load(): Promise<Record<string, unknown>> {
30
+ return Promise.resolve(structuredClone(this.stored))
31
+ }
32
+
33
+ protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
34
+ this.stored[ns] = structuredClone(section)
35
+ return Promise.resolve()
36
+ }
37
+ }
38
+
39
+ class FakeAgentPresets extends Service implements OverlayableAgentPresets {
40
+ constructor(ctx: Context, readonly preset: AgentPreset) {
41
+ super(ctx, 'agentPresets')
42
+ }
43
+
44
+ async resolve(): Promise<AgentPreset> {
45
+ return this.preset
46
+ }
47
+
48
+ async mount(_agentCtx: unknown): Promise<AgentPreset> {
49
+ return await this.resolve()
50
+ }
51
+
52
+ async recompose(_agentCtx: unknown, _id: string): Promise<AgentPreset> {
53
+ return await this.resolve()
54
+ }
55
+
56
+ async standingKeyFor(): Promise<string> {
57
+ return (await this.resolve()).path
58
+ }
59
+ }
60
+
61
+ async function sourcePreset(): Promise<AgentPreset> {
62
+ root = await mkdtemp(join(tmpdir(), 'dsh-selector-host-overlay-'))
63
+ const path = join(root, 'standard', 'agent.cordis.yml')
64
+ await mkdir(dirname(path), { recursive: true })
65
+ await writeFile(path, "- id: persona\n name: '/opt/preset/persona.js'\n")
66
+ return { id: 'standard', trust: 'system', path }
67
+ }
68
+
69
+ describe('context compression selector Host preset integration', () => {
70
+ it('decorates native composition only for the lifetime of the selector fiber', async () => {
71
+ const preset = await sourcePreset()
72
+ ctx = new Context()
73
+ await ctx.plugin(MemorySettings).await()
74
+ await ctx.plugin(FakeAgentPresets, preset).await()
75
+
76
+ const selector = ctx.plugin({ apply: (child) => {
77
+ apply(child, { presetOverlay: true })
78
+ } })
79
+ await selector.await()
80
+
81
+ const service = ctx.agentPresets as unknown as OverlayableAgentPresets
82
+ const mounted = await service.mount({})
83
+ expect(mounted.path).not.toBe(preset.path)
84
+ expect(await readFile(mounted.path, 'utf8')).toContain('id: tool-result-pruner')
85
+ expect((await service.resolve()).path).toBe(preset.path)
86
+
87
+ await selector.dispose()
88
+ expect((await service.mount({})).path).toBe(preset.path)
89
+ })
90
+
91
+ it('keeps settings and overlay alive across duplicate Host row disposal', async () => {
92
+ const preset = await sourcePreset()
93
+ ctx = new Context()
94
+ await ctx.plugin(MemorySettings).await()
95
+ await ctx.plugin(FakeAgentPresets, preset).await()
96
+
97
+ const builtIn = ctx.plugin({ apply })
98
+ await builtIn.await()
99
+
100
+ const namespace = settingsNamespace('context-compression')
101
+ const service = ctx.agentPresets as unknown as OverlayableAgentPresets
102
+ expect((await service.mount({})).path).toBe(preset.path)
103
+ expect(ctx.settings.describe().filter(row => row.ns === namespace)).toHaveLength(1)
104
+
105
+ await ctx.settings.update(namespace, { profile: 'cache-strict' })
106
+ const selectedSettings = structuredClone(ctx.settings.get(namespace))
107
+ expect(selectedSettings).toMatchObject({ profile: 'cache-strict' })
108
+
109
+ const bundle = ctx.plugin({ apply: (child) => {
110
+ apply(child, { presetOverlay: true })
111
+ } })
112
+ await bundle.await()
113
+
114
+ const mounted = await service.mount({})
115
+ expect(mounted.path).not.toBe(preset.path)
116
+ expect((await service.recompose({}, 'standard')).path).toBe(mounted.path)
117
+ expect(ctx.settings.get(namespace)).toEqual(selectedSettings)
118
+ expect(ctx.settings.describe().filter(row => row.ns === namespace)).toHaveLength(1)
119
+
120
+ await builtIn.dispose()
121
+ expect((await service.mount({})).path).toBe(mounted.path)
122
+ expect(ctx.settings.describe().filter(row => row.ns === namespace)).toHaveLength(1)
123
+
124
+ await bundle.dispose()
125
+ expect((await service.mount({})).path).toBe(preset.path)
126
+ expect(ctx.settings.describe().filter(row => row.ns === namespace)).toHaveLength(0)
127
+ })
128
+
129
+ it('writes the saved Auto Compact threshold into the generated compaction-basic composition', async () => {
130
+ const preset = await sourcePreset()
131
+ ctx = new Context()
132
+ await ctx.plugin(MemorySettings).await()
133
+ await ctx.plugin(FakeAgentPresets, preset).await()
134
+ // A settings-owning row must register the namespace before the update.
135
+ await ctx.plugin({ apply }).await()
136
+ const namespace = settingsNamespace('context-compression')
137
+ await ctx.settings.update(namespace, { autoCompact: { thresholdPercent: 73 } })
138
+
139
+ const bundle = ctx.plugin({ apply: (child) => {
140
+ apply(child, { presetOverlay: true })
141
+ } })
142
+ await bundle.await()
143
+
144
+ const service = ctx.agentPresets as unknown as OverlayableAgentPresets
145
+ const mounted = await service.mount({})
146
+ const rendered = await readFile(mounted.path, 'utf8')
147
+ expect(rendered).toContain('id: compaction-basic')
148
+ expect(rendered).toContain('thresholdRatio: 0.73')
149
+ // First-release retention stays pinned at 0.16 next to the threshold.
150
+ expect(rendered).toContain('retainRatio: 0.16')
151
+ // The SAME read feeds the runtime deployment config, so one generation
152
+ // cannot split Auto Compact and micro compact across two thresholds.
153
+ expect(rendered).toContain('id: tool-result-pruner')
154
+ expect(rendered).toContain('autoCompactThresholdPercent: 73')
155
+ await bundle.dispose()
156
+ })
157
+
158
+ it('defaults the generated threshold to 0.8 and keeps it absent from settings until saved', async () => {
159
+ const preset = await sourcePreset()
160
+ ctx = new Context()
161
+ await ctx.plugin(MemorySettings).await()
162
+ await ctx.plugin(FakeAgentPresets, preset).await()
163
+
164
+ const bundle = ctx.plugin({ apply: (child) => {
165
+ apply(child, { presetOverlay: true })
166
+ } })
167
+ await bundle.await()
168
+
169
+ const service = ctx.agentPresets as unknown as OverlayableAgentPresets
170
+ const rendered = await readFile((await service.mount({})).path, 'utf8')
171
+ expect(rendered).toContain('thresholdRatio: 0.8')
172
+ await bundle.dispose()
173
+ })
174
+
175
+ it('moves the standing composition generation when an equal-length threshold changes', async () => {
176
+ const preset = await sourcePreset()
177
+ ctx = new Context()
178
+ await ctx.plugin(MemorySettings).await()
179
+ await ctx.plugin(FakeAgentPresets, preset).await()
180
+ // A settings-owning row must register the namespace before the update.
181
+ await ctx.plugin({ apply }).await()
182
+ const namespace = settingsNamespace('context-compression')
183
+ await ctx.settings.update(namespace, { autoCompact: { thresholdPercent: 70 } })
184
+
185
+ const bundle = ctx.plugin({ apply: (child) => {
186
+ apply(child, { presetOverlay: true })
187
+ } })
188
+ await bundle.await()
189
+
190
+ const service = ctx.agentPresets as unknown as OverlayableAgentPresets
191
+ const first = await service.mount({})
192
+ expect(await readFile(first.path, 'utf8')).toContain('thresholdRatio: 0.7')
193
+ expect(await service.standingKeyFor()).toBe(first.path)
194
+
195
+ // Same character length, different content: the generated identity and
196
+ // standing composition generation must both move to the new threshold.
197
+ await ctx.settings.update(namespace, { autoCompact: { thresholdPercent: 80 } })
198
+ const second = await service.mount({})
199
+ expect(second.path).not.toBe(first.path)
200
+ expect(await readFile(second.path, 'utf8')).toContain('thresholdRatio: 0.8')
201
+ expect(await service.standingKeyFor()).toBe(second.path)
202
+ await bundle.dispose()
203
+ })
204
+ })