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,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
|
+
})
|