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.
- package/.gitattributes +1 -0
- package/.github/workflows/ci.yml +39 -0
- package/CHANGELOG.ja.md +39 -0
- package/CHANGELOG.ko.md +39 -0
- package/CHANGELOG.md +135 -0
- package/CHANGELOG.zh.md +39 -0
- package/CONTRIBUTING.md +22 -0
- package/README.ja.md +104 -0
- package/README.ko.md +103 -0
- package/README.md +89 -12
- package/README.zh.md +87 -12
- package/SECURITY.md +18 -0
- package/THIRD_PARTY_NOTICES.md +7 -31
- package/docs/installation.ja.md +76 -0
- package/docs/installation.ko.md +76 -0
- package/docs/installation.md +76 -0
- package/docs/installation.zh.md +76 -0
- package/docs/repair-log.md +582 -0
- package/eslint.config.js +30 -0
- package/package.json +84 -81
- package/packages/selector/LICENSE +21 -0
- package/packages/selector/README.md +26 -0
- package/packages/selector/README.zh.md +26 -0
- package/packages/selector/THIRD_PARTY_NOTICES.md +38 -0
- package/packages/selector/docs/history-tool-call-working-set-spec.md +112 -0
- package/packages/selector/docs/native-tool-result-selector-spec.md +34 -0
- package/packages/selector/docs/subagent-cache-reuse-spec.md +46 -0
- package/packages/selector/lib/style.css +308 -0
- package/packages/selector/package.json +115 -0
- package/{screenshots.json → packages/selector/screenshots.json} +6 -6
- package/packages/selector/src/client/CompressionProfileControls.tsx +229 -0
- package/packages/selector/src/client/CompressionProfileSelector.module.css +170 -0
- package/packages/selector/src/client/CompressionProfileSelector.tsx +79 -0
- package/packages/selector/src/client/CustomPolicyEditor.tsx +216 -0
- package/packages/selector/src/client/EstimatorControls.tsx +281 -0
- package/packages/selector/src/client/decode.ts +49 -0
- package/packages/selector/src/client/index.ts +111 -0
- package/packages/selector/src/client/locales.ts +198 -0
- package/packages/selector/src/client/preset-options.ts +70 -0
- package/packages/selector/src/client/settings-section.tsx +126 -0
- package/packages/selector/src/css-modules.d.ts +6 -0
- package/packages/selector/src/deepseek-v4-tokenizer.ts +210 -0
- package/packages/selector/src/estimator-catalog.ts +104 -0
- package/packages/selector/src/index.ts +327 -0
- package/packages/selector/src/invariant.ts +113 -0
- package/packages/selector/src/preset-overlay.ts +567 -0
- package/packages/selector/src/profiles.ts +342 -0
- package/packages/selector/src/pruner/content.ts +188 -0
- package/packages/selector/src/pruner/session.ts +94 -0
- package/packages/selector/src/pruner/state.ts +43 -0
- package/packages/selector/src/pruner/tuning.ts +23 -0
- package/packages/selector/src/pruner/types.ts +60 -0
- package/packages/selector/src/pruner.ts +2144 -0
- package/packages/selector/src/runtime/adaptive-cost.ts +194 -0
- package/packages/selector/src/runtime/audit.ts +215 -0
- package/packages/selector/src/runtime/config.ts +613 -0
- package/packages/selector/src/runtime/custom-policy.ts +278 -0
- package/packages/selector/src/runtime/deepseek-official-pricing.ts +298 -0
- package/packages/selector/src/runtime/deepseek-v4-vision-tokens.ts +254 -0
- package/packages/selector/src/runtime/measurement.ts +403 -0
- package/packages/selector/src/runtime/reducers.ts +656 -0
- package/packages/selector/src/runtime/retrieve.ts +457 -0
- package/packages/selector/src/runtime/session-events.ts +17 -0
- package/packages/selector/src/runtime/tail-trim.ts +166 -0
- package/packages/selector/src/runtime/token-count.ts +72 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +81 -0
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +183 -0
- package/packages/selector/src/runtime/tokenpilot/locator.ts +128 -0
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +77 -0
- package/packages/selector/src/runtime/types.ts +309 -0
- package/packages/selector/src/runtime/value.ts +48 -0
- package/packages/selector/tests/auto-compact.client.spec.tsx +226 -0
- package/packages/selector/tests/built/client-artifact.spec.ts +51 -0
- package/packages/selector/tests/cache-prefix-audit.spec.ts +123 -0
- package/packages/selector/tests/code-skeleton.client.spec.ts +88 -0
- package/packages/selector/tests/custom-contract.client.spec.ts +202 -0
- package/packages/selector/tests/estimator-catalog.spec.ts +70 -0
- package/packages/selector/tests/estimator-channel.client.spec.tsx +247 -0
- package/packages/selector/tests/estimator-route-registration.host.spec.ts +176 -0
- package/packages/selector/tests/host-preset-overlay.host.spec.ts +204 -0
- package/packages/selector/tests/preset-options-write.client.spec.ts +181 -0
- package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +196 -0
- package/packages/selector/tests/preset-overlay.host.spec.ts +243 -0
- package/packages/selector/tests/profiles.client.spec.tsx +434 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +33 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +167 -0
- package/packages/selector/tests/runtime/audit.spec.ts +129 -0
- package/packages/selector/tests/runtime/auto-compact-config.spec.ts +523 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +141 -0
- package/packages/selector/tests/runtime/deepseek-official-pricing.spec.ts +186 -0
- package/packages/selector/tests/runtime/deepseek-v4-tokenizer.spec.ts +122 -0
- package/packages/selector/tests/runtime/deepseek-v4-vision-tokens.spec.ts +122 -0
- package/packages/selector/tests/runtime/fixtures/profile-baseline.json +273 -0
- package/packages/selector/tests/runtime/fixtures/tokenizer-golden.json +106 -0
- package/packages/selector/tests/runtime/fixtures/vision-golden.json +459 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +2531 -0
- package/packages/selector/tests/runtime/session-events.spec.ts +27 -0
- package/packages/selector/tests/runtime/tokenizer-golden.spec.ts +53 -0
- package/packages/selector/tests/runtime/tokenpilot/dedup.spec.ts +52 -0
- package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +56 -0
- package/packages/selector/tests/runtime/tokenpilot/locator.spec.ts +76 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +100 -0
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +58 -0
- package/packages/selector/tests/runtime/value.spec.ts +23 -0
- package/packages/selector/tests/standing-generation.host.spec.ts +631 -0
- package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +250 -0
- package/packages/selector/tests/support/cache-prefix-audit.ts +105 -0
- package/packages/selector/tests/support/mock-adapter.ts +37 -0
- package/packages/selector/tests/support/ui-primitives.tsx +34 -0
- package/packages/selector/tsconfig.json +11 -0
- package/packages/selector/tsdown.client.config.ts +102 -0
- package/packages/selector/tsdown.config.ts +20 -0
- package/pnpm-workspace.yaml +19 -0
- package/scripts/capture-profile-baseline.ts +80 -0
- package/scripts/generate-tokenizer-fixtures.py +81 -0
- package/scripts/generate-vision-fixtures.py +208 -0
- package/scripts/packed-components-smoke.ts +713 -0
- package/scripts/packed-install-e2e.ts +1072 -0
- package/scripts/verify-release.ts +300 -0
- package/tests/TEST_INVENTORY.md +42 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +7 -0
- package/tsconfig.scripts.json +13 -0
- package/tsconfig.tests.json +15 -0
- package/vitest.built.config.ts +9 -0
- package/vitest.config.ts +43 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/LICENSE.DeepSeek-V4-Pro.txt +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/manifest.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer_config.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/LICENSE.DeepSeek-V4-Flash-Vision-Exp.txt +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/manifest.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer_config.json +0 -0
- /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-profiles.jpg +0 -0
- /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-settings.png +0 -0
- /package/{cordis.patch.yml → packages/selector/cordis.patch.yml} +0 -0
- /package/{dsh.plugin.json → packages/selector/dsh.plugin.json} +0 -0
- /package/{lib → packages/selector/lib}/client.d.ts +0 -0
- /package/{lib → packages/selector/lib}/client.js +0 -0
- /package/{lib → packages/selector/lib}/config.js +0 -0
- /package/{lib → packages/selector/lib}/index.d.ts +0 -0
- /package/{lib → packages/selector/lib}/index.js +0 -0
- /package/{lib → packages/selector/lib}/invariant.d.ts +0 -0
- /package/{lib → packages/selector/lib}/invariant.js +0 -0
- /package/{lib → packages/selector/lib}/pruner.d.ts +0 -0
- /package/{lib → packages/selector/lib}/pruner.js +0 -0
- /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
|
+
})
|