dsh-compact-saviour 0.2.7 → 0.2.9
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/README.md +14 -8
- package/dshx.yml +1 -0
- package/lib/client.js +10 -17
- package/lib/client.js.map +1 -1
- package/lib/dsh-compact-saviour.js +133 -28
- package/lib/types/compress.d.ts +3 -1
- package/lib/types/compress.d.ts.map +1 -1
- package/lib/types/compress.js +22 -6
- package/lib/types/compress.js.map +1 -1
- package/lib/types/controller.d.ts +3 -1
- package/lib/types/controller.d.ts.map +1 -1
- package/lib/types/controller.js +31 -11
- package/lib/types/controller.js.map +1 -1
- package/lib/types/dsh-compact-saviour.d.ts +2 -0
- package/lib/types/dsh-compact-saviour.d.ts.map +1 -1
- package/lib/types/dsh-compact-saviour.js +6 -1
- package/lib/types/dsh-compact-saviour.js.map +1 -1
- package/lib/types/routes.d.ts +26 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/routes.js +65 -0
- package/lib/types/routes.js.map +1 -0
- package/package.json +22 -21
- package/scripts/link-harness-dependencies.mjs +22 -6
- package/src/compress.ts +19 -6
- package/src/controller.ts +26 -15
- package/src/dsh-compact-saviour.ts +7 -1
- package/src/routes.ts +76 -0
- package/tests/compression.test.ts +1 -1
- package/tests/peer-range.test.ts +8 -8
- package/tests/routes.test.ts +67 -0
package/src/controller.ts
CHANGED
|
@@ -12,11 +12,12 @@ import type {} from '@deepseek-ai/dsh-session-projection';
|
|
|
12
12
|
import { compactSurfaceRegion, selectCompactableRange } from './vendor/region.js';
|
|
13
13
|
import { compress } from './compress.js';
|
|
14
14
|
import { PressureMeter } from './pressure.js';
|
|
15
|
+
import type { CompactionRouteLease, CompactionRouteRegistry } from './routes.js';
|
|
15
16
|
import type { Config, Target, State, JobView } from './shared.js';
|
|
16
17
|
|
|
17
18
|
type Engine = BasicCompactionEngine;
|
|
18
19
|
interface Binding { engine: Engine; ctx: Context; fiber: Fiber; restore(): void }
|
|
19
|
-
interface Job { view: JobView; pending?: Target; abort?: AbortController; blocked: boolean; work?: Promise<unknown> }
|
|
20
|
+
interface Job { view: JobView; pending?: Target; pendingRoute?: CompactionRouteLease; abort?: AbortController; blocked: boolean; work?: Promise<unknown> }
|
|
20
21
|
const inactive = (): Job => ({ view: { phase: 'idle', message: '', failures: 0 }, blocked: false });
|
|
21
22
|
|
|
22
23
|
export class Controller {
|
|
@@ -24,7 +25,7 @@ export class Controller {
|
|
|
24
25
|
private jobs = new Map<string, Job>();
|
|
25
26
|
private disposed = false;
|
|
26
27
|
private pressure: PressureMeter;
|
|
27
|
-
constructor(readonly ctx: Context, readonly settings: SettingsScope<Config
|
|
28
|
+
constructor(readonly ctx: Context, readonly settings: SettingsScope<Config>, private readonly routes?: CompactionRouteRegistry) {
|
|
28
29
|
this.pressure = new PressureMeter(ctx);
|
|
29
30
|
const scan = () => { if (!this.disposed) this.scan(); };
|
|
30
31
|
ctx.on('internal/status', () => queueMicrotask(scan), { global: true });
|
|
@@ -69,7 +70,8 @@ export class Controller {
|
|
|
69
70
|
const region: Engine['compactRegion'] = async function(this: Engine, start, end, agent, signal) {
|
|
70
71
|
const job = self.job(agent);
|
|
71
72
|
if (job.blocked && self.settings.get().enabled) throw new Error('压缩救援等待用户选择重试或取消。');
|
|
72
|
-
|
|
73
|
+
const route = self.settings.get().enabled ? self.routes?.resolve(agent) : undefined;
|
|
74
|
+
if (self.direct() || route) return self.rescue(agent, { engine, ctx: fiber.ctx, fiber, restore() {} }, route?.target ?? self.configured(), signal ?? new AbortController().signal, false, { start, end }, undefined, route);
|
|
73
75
|
const generation = agent.session.surface.replaceGeneration;
|
|
74
76
|
const firstEvent = agent.session.seq;
|
|
75
77
|
try {
|
|
@@ -91,7 +93,7 @@ export class Controller {
|
|
|
91
93
|
if (self.job(agent).blocked && self.settings.get().enabled) return null;
|
|
92
94
|
// Prime metadata before the official synchronous pressure check. Failure
|
|
93
95
|
// to resolve it leaves the original check and context-overflow recovery intact.
|
|
94
|
-
try { await self.pressure.refresh(agent.session,
|
|
96
|
+
try { await self.pressure.refresh(agent.session, self.current(agent), signal); } catch { if (signal?.aborted) throw signal.reason; }
|
|
95
97
|
return originalIfNeeded.call(this, agent, trigger, signal);
|
|
96
98
|
};
|
|
97
99
|
const now: Engine['compactNow'] = function(this: Engine, agent, signal, sourceCommandId) {
|
|
@@ -131,6 +133,8 @@ export class Controller {
|
|
|
131
133
|
}
|
|
132
134
|
private direct(): boolean { const config = this.settings.get(); return config.enabled && config.mode === 'direct'; }
|
|
133
135
|
current(agent: Agent): Target | undefined {
|
|
136
|
+
const routed = this.routes?.resolve(agent);
|
|
137
|
+
if (routed) return routed.context;
|
|
134
138
|
const projection = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection');
|
|
135
139
|
const target = projection?.pending ?? projection?.lastUsed ?? agent.session.requestHeader()?.config ?? agent.options;
|
|
136
140
|
return target.provider && target.model ? { provider: target.provider, model: target.model, ...(target.reasoningEffort ? { reasoningEffort: String(target.reasoningEffort) } : {}) } : undefined;
|
|
@@ -156,15 +160,16 @@ export class Controller {
|
|
|
156
160
|
const job = this.job(agent);
|
|
157
161
|
if (mode === 'cancel') {
|
|
158
162
|
job.blocked = false;
|
|
159
|
-
job.pending = undefined; job.abort?.abort(new Error('用户取消压缩。'));
|
|
163
|
+
job.pending = undefined; job.pendingRoute = undefined; job.abort?.abort(new Error('用户取消压缩。'));
|
|
160
164
|
if (!job.abort) job.view = { phase: 'idle', message: '已取消压缩。', failures: job.view.failures };
|
|
161
165
|
return this.state(id);
|
|
162
166
|
}
|
|
163
167
|
if (job.pending || job.abort) throw new Error('该会话已有压缩任务。');
|
|
164
168
|
this.binding(agent);
|
|
165
|
-
const
|
|
169
|
+
const route = this.routes?.resolve(agent);
|
|
170
|
+
const target = route?.target ?? (mode === 'current' ? this.current(agent) : this.configured());
|
|
166
171
|
if (!target?.provider || !target.model) throw new Error('请先在设置中选择压缩模型。');
|
|
167
|
-
job.pending = target; job.blocked = false;
|
|
172
|
+
job.pending = target; job.pendingRoute = route; job.blocked = false;
|
|
168
173
|
job.view = { phase: 'queued', message: agent.status === 'idle' ? '正在准备压缩…' : '已排队,当前模型或工具步骤结束后压缩。', failures: job.view.failures };
|
|
169
174
|
if (agent.status === 'idle') queueMicrotask(() => { void this.runIdle(agent); });
|
|
170
175
|
return this.state(id);
|
|
@@ -176,31 +181,35 @@ export class Controller {
|
|
|
176
181
|
catch (error) {
|
|
177
182
|
// A turn may win admission between the click and maintenance. Keep it queued for pre-step.
|
|
178
183
|
if (agent.status !== 'idle') return;
|
|
179
|
-
job.pending = undefined; this.fail(job, error);
|
|
184
|
+
job.pending = undefined; job.pendingRoute = undefined; this.fail(job, error);
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
private async runPending(agent: Agent, signal: AbortSignal, idle: boolean): Promise<void> {
|
|
183
|
-
const job = this.job(agent), target = job.pending;
|
|
188
|
+
const job = this.job(agent), target = job.pending, route = job.pendingRoute;
|
|
184
189
|
if (!target || this.disposed) return;
|
|
185
|
-
job.pending = undefined;
|
|
186
|
-
try { await this.rescue(agent, this.binding(agent), target, signal, idle); }
|
|
190
|
+
job.pending = undefined; job.pendingRoute = undefined;
|
|
191
|
+
try { await this.rescue(agent, this.binding(agent), target, signal, idle, undefined, undefined, route); }
|
|
187
192
|
catch (error) { if (!signal.aborted && job.view.phase === 'queued') this.fail(job, error); }
|
|
188
193
|
}
|
|
189
194
|
private fail(job: Job, error: unknown): void {
|
|
190
195
|
job.blocked = true;
|
|
191
196
|
job.view = { ...job.view, phase: 'failed', message: errorChain(error) };
|
|
192
197
|
}
|
|
193
|
-
private async rescue(agent: Agent, binding: Binding, target: Target, outerSignal: AbortSignal, idle: boolean, selected?: { start: SessionSeq; end: SessionSeq }, sourceCommandId?: CommandId) {
|
|
198
|
+
private async rescue(agent: Agent, binding: Binding, target: Target, outerSignal: AbortSignal, idle: boolean, selected?: { start: SessionSeq; end: SessionSeq }, sourceCommandId?: CommandId, lease?: CompactionRouteLease) {
|
|
194
199
|
const job = this.job(agent);
|
|
195
200
|
if (job.abort) throw new Error('该会话已有压缩任务。');
|
|
196
201
|
const abort = new AbortController(); job.abort = abort;
|
|
197
|
-
|
|
202
|
+
let signal = AbortSignal.any([outerSignal, abort.signal]);
|
|
198
203
|
const work = (async () => {
|
|
199
204
|
try {
|
|
200
205
|
signal.throwIfAborted();
|
|
206
|
+
const route = lease ?? this.routes?.resolve(agent);
|
|
207
|
+
if (route) signal = AbortSignal.any([signal, route.signal]);
|
|
208
|
+
route?.assertCurrent();
|
|
209
|
+
target = route?.target ?? target;
|
|
201
210
|
if (!target.provider || !target.model) throw new Error('请先在设置 → Compact Saviour 中选择压缩模型。');
|
|
202
211
|
const meter = binding.ctx.tokenMeter;
|
|
203
|
-
const current = this.current(agent);
|
|
212
|
+
const current = route?.context ?? this.current(agent);
|
|
204
213
|
const header = agent.session.requestHeader();
|
|
205
214
|
// Reprice retained images for the model selected for the next request.
|
|
206
215
|
const effectiveHeader = header && current ? { ...header, config: { ...header.config, provider: current.provider, model: current.model } } : header;
|
|
@@ -215,6 +224,8 @@ export class Controller {
|
|
|
215
224
|
const beforeTokens = fixed + before.surfaceTokens;
|
|
216
225
|
job.view = { phase: 'running', message: '正在压缩上下文…', failures: job.view.failures, beforeTokens };
|
|
217
226
|
const info = current ? await binding.ctx.llm.resolveModelInfo(current.provider, current.model, signal) : undefined;
|
|
227
|
+
signal.throwIfAborted(); route?.assertCurrent();
|
|
228
|
+
if (info && (info.provider !== current?.provider || info.id !== current.model)) throw new Error('上下文模型元数据与实际路由不一致。');
|
|
218
229
|
const contextWindow = info?.context?.contextWindow;
|
|
219
230
|
if (!contextWindow) throw new Error('当前对话模型没有可确认的上下文容量。');
|
|
220
231
|
const room = Math.floor(contextWindow * .75) - fixed - (effectiveHeader?.config.maxTokens ?? info?.defaultMaxTokens ?? 8192);
|
|
@@ -229,7 +240,7 @@ export class Controller {
|
|
|
229
240
|
const summaryBudget = Math.min(6000, maxSummaryTokens);
|
|
230
241
|
const result = await compactSurfaceRegion({
|
|
231
242
|
meter,
|
|
232
|
-
summarize: (input, owner, abortSignal) => compress(binding.ctx, input, owner, { target, signal: abortSignal ?? signal, summaryBudget, maxSummaryTokens,
|
|
243
|
+
summarize: (input, owner, abortSignal) => compress(binding.ctx, input, owner, { target, signal: abortSignal ?? signal, summaryBudget, maxSummaryTokens, routeLease: route,
|
|
233
244
|
progress: (completed, total, message) => { job.view = { ...job.view, completed, total, message: message ?? (total === 1 ? '正在生成摘要…' : `正在分块压缩 ${completed}/${total}…`) }; },
|
|
234
245
|
}),
|
|
235
246
|
}, agent.session, range.start, range.end, agent, { owner: idle ? null : 'current-turn', stability: idle ? 'selected-span' : 'whole-surface', ...(sourceCommandId ? { sourceCommandId } : {}), ...(idle ? { flush: async () => { await binding.ctx.sessions.flush(agent.session); } } : {}) }, signal);
|
|
@@ -6,6 +6,9 @@ import type {} from '@deepseek-ai/dsh-settings';
|
|
|
6
6
|
import type {} from '@deepseek-ai/dsh-llm';
|
|
7
7
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
8
8
|
import { Controller } from './controller.js';
|
|
9
|
+
import { CompactionRouteRegistry } from './routes.js';
|
|
10
|
+
export { CompactionRouteRegistry } from './routes.js';
|
|
11
|
+
export type { CompactionRouteSnapshot, CompactionRouteLease } from './routes.js';
|
|
9
12
|
import type { Config as Settings } from './shared.js';
|
|
10
13
|
|
|
11
14
|
export const name = 'dsh-compact-saviour';
|
|
@@ -41,7 +44,10 @@ export function apply(ctx: Context, config: { [K in keyof Settings]-?: Volatile<
|
|
|
41
44
|
replace: (value: Settings) => ctx.settings.update('dsh-compact-saviour', value),
|
|
42
45
|
};
|
|
43
46
|
ctx.effect(() => ctx.settings.configure({ auto: false }));
|
|
44
|
-
const
|
|
47
|
+
const routes = new CompactionRouteRegistry();
|
|
48
|
+
ctx.provide('compactionRoutes', routes);
|
|
49
|
+
ctx.effect(() => () => routes.close());
|
|
50
|
+
const controller = new Controller(ctx, settings, routes);
|
|
45
51
|
// Versioned route also recovers a running 0.1.0 Host whose unowned legacy
|
|
46
52
|
// route cannot be released through the public WebServer API. All new route
|
|
47
53
|
// registrations are fiber-owned and disappear before replacement on HMR.
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
2
|
+
import type { GenerateOptions } from '@deepseek-ai/dsh-llm';
|
|
3
|
+
import type { Target } from './shared.js';
|
|
4
|
+
|
|
5
|
+
/** Optional plugin contract. Capacity is resolved separately for these exact routes. */
|
|
6
|
+
export interface CompactionRouteSnapshot {
|
|
7
|
+
revision: string;
|
|
8
|
+
context: Target;
|
|
9
|
+
target: Target;
|
|
10
|
+
maxOutputTokens?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface CompactionRouteLease extends CompactionRouteSnapshot {
|
|
13
|
+
readonly signal: AbortSignal;
|
|
14
|
+
assertCurrent(): void;
|
|
15
|
+
prepare(request: GenerateOptions): GenerateOptions;
|
|
16
|
+
}
|
|
17
|
+
type Resolver = (agent: Agent) => CompactionRouteSnapshot | undefined;
|
|
18
|
+
|
|
19
|
+
function snapshot(value: CompactionRouteSnapshot): CompactionRouteSnapshot {
|
|
20
|
+
if (!value.revision || ![value.context, value.target].every(route =>
|
|
21
|
+
typeof route?.provider === 'string' && route.provider && typeof route.model === 'string' && route.model
|
|
22
|
+
&& (route.reasoningEffort === undefined || typeof route.reasoningEffort === 'string' && route.reasoningEffort)))
|
|
23
|
+
throw new Error('Invalid session compaction route');
|
|
24
|
+
if (value.maxOutputTokens !== undefined && (!Number.isSafeInteger(value.maxOutputTokens) || value.maxOutputTokens <= 0))
|
|
25
|
+
throw new Error('Invalid session compaction output reservation');
|
|
26
|
+
return Object.freeze({ revision: value.revision,
|
|
27
|
+
context: Object.freeze({ ...value.context }), target: Object.freeze({ ...value.target }),
|
|
28
|
+
...(value.maxOutputTokens === undefined ? {} : { maxOutputTokens: value.maxOutputTokens }) });
|
|
29
|
+
}
|
|
30
|
+
const key = (value: CompactionRouteSnapshot) => JSON.stringify([value.revision,
|
|
31
|
+
value.context.provider, value.context.model, value.context.reasoningEffort,
|
|
32
|
+
value.target.provider, value.target.model, value.target.reasoningEffort, value.maxOutputTokens]);
|
|
33
|
+
|
|
34
|
+
/** Fiber-owned registrations; a lease expires on route change or owner unload. */
|
|
35
|
+
export class CompactionRouteRegistry {
|
|
36
|
+
readonly apiVersion = 1;
|
|
37
|
+
private resolvers = new Set<{ owner: string; resolve: Resolver; lifecycle: AbortController }>();
|
|
38
|
+
register(owner: string, resolve: Resolver): () => void {
|
|
39
|
+
if (!owner) throw new Error('Compaction route owner is required');
|
|
40
|
+
const entry = { owner, resolve, lifecycle: new AbortController() }; this.resolvers.add(entry);
|
|
41
|
+
return () => { this.resolvers.delete(entry); entry.lifecycle.abort(new Error('Session compaction route owner unloaded')); };
|
|
42
|
+
}
|
|
43
|
+
resolve(agent: Agent): CompactionRouteLease | undefined {
|
|
44
|
+
const matches = [...this.resolvers].flatMap(entry => {
|
|
45
|
+
const value = entry.resolve(agent); return value ? [{ entry, value: snapshot(value) }] : [];
|
|
46
|
+
});
|
|
47
|
+
if (!matches.length) return undefined;
|
|
48
|
+
if (matches.length !== 1) throw new Error('Multiple plugins own this session compaction route');
|
|
49
|
+
const { entry, value } = matches[0]!;
|
|
50
|
+
const signature = key(value);
|
|
51
|
+
const assertCurrent = () => {
|
|
52
|
+
if ([...this.resolvers].some(other => other !== entry && other.resolve(agent)))
|
|
53
|
+
throw new Error('Multiple plugins own this session compaction route');
|
|
54
|
+
const current = this.resolvers.has(entry) && entry.resolve(agent);
|
|
55
|
+
if (!current || key(snapshot(current)) !== signature) throw new Error('Session compaction route changed; retry with the current model');
|
|
56
|
+
};
|
|
57
|
+
return Object.freeze({ ...value, signal: entry.lifecycle.signal, assertCurrent,
|
|
58
|
+
prepare: (request: GenerateOptions) => {
|
|
59
|
+
request.signal?.throwIfAborted(); assertCurrent();
|
|
60
|
+
if (request.purpose !== 'compaction' || request.sessionId !== agent.session.id
|
|
61
|
+
|| request.provider !== value.target.provider || request.model !== value.target.model
|
|
62
|
+
|| request.reasoningEffort !== value.target.reasoningEffort
|
|
63
|
+
|| !Number.isSafeInteger(request.maxTokens) || Number(request.maxTokens) <= 0
|
|
64
|
+
|| value.maxOutputTokens !== undefined && request.maxTokens! > value.maxOutputTokens)
|
|
65
|
+
throw new Error('Compaction request does not match its planned physical route');
|
|
66
|
+
// Host llm.stream supports frozen one-shot envelopes. Later middleware
|
|
67
|
+
// may validate this route, but cannot replace its model or reservation.
|
|
68
|
+
return Object.freeze(request);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
close(): void {
|
|
73
|
+
for (const entry of this.resolvers) entry.lifecycle.abort(new Error('Session compaction route registry unloaded'));
|
|
74
|
+
this.resolvers.clear();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -14,7 +14,7 @@ test('chunks preserve multilingual source exactly, including a single huge line'
|
|
|
14
14
|
function fixture(text: string, finish = 'stop') {
|
|
15
15
|
const requests: any[] = [];
|
|
16
16
|
const ctx = { llm: {
|
|
17
|
-
resolveModelInfo: async () => ({ context: { contextWindow: 64000 }, reasoning: { efforts: [{ id: 'low' }] } }),
|
|
17
|
+
resolveModelInfo: async (provider: string, model: string) => ({ provider, id: model, name: model, context: { contextWindow: 64000 }, reasoning: { efforts: [{ id: 'low' }] } }),
|
|
18
18
|
async *stream(request: any) {
|
|
19
19
|
requests.push(request);
|
|
20
20
|
yield { type: 'block-start', index: 0, blockType: 'text' };
|
package/tests/peer-range.test.ts
CHANGED
|
@@ -3,19 +3,19 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import test from 'node:test';
|
|
4
4
|
import semver from 'semver';
|
|
5
5
|
|
|
6
|
-
const RANGE = '>=0.2.
|
|
7
|
-
const ACCEPT = ['0.2.
|
|
8
|
-
const REJECT = ['0.2.0-alpha', '0.2.0-alpha.1', '0.2.
|
|
6
|
+
const RANGE = '>=0.2.1-rc.1 <0.2.2';
|
|
7
|
+
const ACCEPT = ['0.2.1-rc.5', '0.2.1-rc.1', '0.2.1'];
|
|
8
|
+
const REJECT = ['0.2.0-rc.2', '0.2.0', '0.2.0-alpha', '0.2.0-alpha.1', '0.2.1-alpha.1', '0.1.7-rc.2', '0.1.7-rc.1', '0.2.2'];
|
|
9
9
|
|
|
10
|
-
test('Harness peers use >=0.2.
|
|
10
|
+
test('Harness peers use >=0.2.1-rc.1 <0.2.2', () => {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
12
|
-
assert.equal(pkg.version, '0.2.
|
|
13
|
-
assert.equal(semver.satisfies('0.2.
|
|
12
|
+
assert.equal(pkg.version, '0.2.9');
|
|
13
|
+
assert.equal(semver.satisfies('0.2.1-rc.5', RANGE), true);
|
|
14
14
|
const peers = Object.entries(pkg.peerDependencies).filter(([name]) => name.startsWith('@deepseek-ai/dsh'));
|
|
15
15
|
assert.ok(peers.length >= 17);
|
|
16
16
|
for (const [name, range] of peers) assert.equal(range, RANGE, name);
|
|
17
|
-
assert.equal(pkg.devDependencies['@deepseek-ai/dsh-agent-loop'],
|
|
18
|
-
assert.equal(pkg.devDependencies['@deepseek-ai/dsh-agent-loop-testkit'],
|
|
17
|
+
assert.equal(pkg.devDependencies['@deepseek-ai/dsh-agent-loop'], RANGE);
|
|
18
|
+
assert.equal(pkg.devDependencies['@deepseek-ai/dsh-agent-loop-testkit'], RANGE);
|
|
19
19
|
assert.equal(pkg.peerDependencies['@deepseek-ai/cordis'], '^4.0.4');
|
|
20
20
|
assert.equal(pkg.peerDependencies['@deepseek-ai/schemastery'], '^3.18.4');
|
|
21
21
|
for (const version of ACCEPT) assert.equal(semver.satisfies(version, RANGE), true, version);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CompactionRouteRegistry } from '../src/routes.js';
|
|
4
|
+
import { compress } from '../src/compress.js';
|
|
5
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
6
|
+
|
|
7
|
+
const agent = (id = 'a') => ({ id, session: { id } }) as never;
|
|
8
|
+
const value = (revision = '1') => ({ revision, context: { provider: 'p', model: 'lead' },
|
|
9
|
+
target: { provider: 'p', model: 'summary' }, maxOutputTokens: 2048 });
|
|
10
|
+
|
|
11
|
+
test('session leases isolate routes and expire on revision change or owner unload', () => {
|
|
12
|
+
const registry = new CompactionRouteRegistry(); let revision = '1';
|
|
13
|
+
const remove = registry.register('fixture', a => a.session.id === 'a' ? value(revision) : undefined);
|
|
14
|
+
const first = registry.resolve(agent())!;
|
|
15
|
+
assert.equal(registry.resolve(agent('ordinary')), undefined);
|
|
16
|
+
first.assertCurrent(); revision = '2';
|
|
17
|
+
assert.throws(() => first.assertCurrent(), /route changed/);
|
|
18
|
+
const second = registry.resolve(agent())!; remove();
|
|
19
|
+
assert.throws(() => second.assertCurrent(), /route changed/);
|
|
20
|
+
assert.equal(second.signal.aborted, true);
|
|
21
|
+
});
|
|
22
|
+
test('ambiguous owners fail instead of choosing whichever plugin registered first', () => {
|
|
23
|
+
const registry = new CompactionRouteRegistry();
|
|
24
|
+
registry.register('one', () => value());
|
|
25
|
+
const lease = registry.resolve(agent())!;
|
|
26
|
+
registry.register('two', () => value());
|
|
27
|
+
assert.throws(() => registry.resolve(agent()), /Multiple plugins/);
|
|
28
|
+
assert.throws(() => lease.assertCurrent(), /Multiple plugins/);
|
|
29
|
+
});
|
|
30
|
+
test('a planned request retains its bounded cap and cannot be rerouted', () => {
|
|
31
|
+
const registry = new CompactionRouteRegistry(); registry.register('fixture', () => value());
|
|
32
|
+
const lease = registry.resolve(agent())!;
|
|
33
|
+
const request = { provider: 'p', model: 'summary', sessionId: 'a', purpose: 'compaction', maxTokens: 1024, messages: [] };
|
|
34
|
+
const planned = lease.prepare(request as never);
|
|
35
|
+
assert.equal(planned.maxTokens, 1024); assert.equal(Object.isFrozen(planned), true);
|
|
36
|
+
assert.throws(() => { planned.model = 'other'; });
|
|
37
|
+
assert.throws(() => lease.prepare({ ...request, maxTokens: 4096 } as never), /planned physical route/);
|
|
38
|
+
assert.throws(() => lease.prepare({ ...request, model: 'other' } as never), /planned physical route/);
|
|
39
|
+
});
|
|
40
|
+
test('physical planning uses the lease model and cap before dispatch, then rejects stale metadata', async () => {
|
|
41
|
+
const registry = new CompactionRouteRegistry(); let revision = '1';
|
|
42
|
+
registry.register('fixture', () => value(revision));
|
|
43
|
+
const input = { messages: [createUserMessage({ content: [{ type: 'text', text: 'Preserve synthetic task facts. '.repeat(500) }] })] };
|
|
44
|
+
const requests: any[] = [], metadata: string[] = [];
|
|
45
|
+
const ctx = { llm: {
|
|
46
|
+
async resolveModelInfo(provider: string, model: string) {
|
|
47
|
+
metadata.push(model); return { provider, id: model, name: model, context: { contextWindow: 32000 }, defaultMaxTokens: 8192 };
|
|
48
|
+
},
|
|
49
|
+
async *stream(request: any) {
|
|
50
|
+
requests.push(request); assert.equal(Object.isFrozen(request), true);
|
|
51
|
+
yield { type: 'block-start', index: 0, blockType: 'text' };
|
|
52
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Preserve task facts. Next: verify.' } };
|
|
53
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
54
|
+
},
|
|
55
|
+
} };
|
|
56
|
+
const lease = registry.resolve(agent())!;
|
|
57
|
+
await compress(ctx as never, input, agent(), { target: lease.target, routeLease: lease,
|
|
58
|
+
signal: new AbortController().signal, progress() {} });
|
|
59
|
+
assert.deepEqual(metadata, ['summary']); assert.equal(requests[0].maxTokens, 2048);
|
|
60
|
+
assert.equal(requests[0].model, 'summary');
|
|
61
|
+
ctx.llm.resolveModelInfo = async (provider, model) => {
|
|
62
|
+
revision = '2'; return { provider, id: model, name: model, context: { contextWindow: 32000 }, defaultMaxTokens: 8192 };
|
|
63
|
+
};
|
|
64
|
+
await assert.rejects(compress(ctx as never, input, agent(), { target: lease.target, routeLease: lease,
|
|
65
|
+
signal: new AbortController().signal, progress() {} }), /route changed/);
|
|
66
|
+
assert.equal(requests.length, 1);
|
|
67
|
+
});
|