dsh-compact-saviour 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/ACCEPTANCE.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -0
  4. package/THIRD_PARTY_NOTICES.md +50 -0
  5. package/cordis.patch.yml +3 -0
  6. package/cordis.yml +2 -0
  7. package/docs/development.md +28 -0
  8. package/dshx.yml +16 -0
  9. package/lib/client.js +5933 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/dsh-compact-saviour.js +1206 -0
  12. package/lib/types/client/context-elements.d.ts +6 -0
  13. package/lib/types/client/context-elements.d.ts.map +1 -0
  14. package/lib/types/client/context-elements.js +41 -0
  15. package/lib/types/client/context-elements.js.map +1 -0
  16. package/lib/types/client/index.d.ts +10 -0
  17. package/lib/types/client/index.d.ts.map +1 -0
  18. package/lib/types/client/index.js +217 -0
  19. package/lib/types/client/index.js.map +1 -0
  20. package/lib/types/client/meter-bridge.d.ts +12 -0
  21. package/lib/types/client/meter-bridge.d.ts.map +1 -0
  22. package/lib/types/client/meter-bridge.js +89 -0
  23. package/lib/types/client/meter-bridge.js.map +1 -0
  24. package/lib/types/client/ring-control.d.ts +32 -0
  25. package/lib/types/client/ring-control.d.ts.map +1 -0
  26. package/lib/types/client/ring-control.js +191 -0
  27. package/lib/types/client/ring-control.js.map +1 -0
  28. package/lib/types/client/satellite.d.ts +32 -0
  29. package/lib/types/client/satellite.d.ts.map +1 -0
  30. package/lib/types/client/satellite.js +163 -0
  31. package/lib/types/client/satellite.js.map +1 -0
  32. package/lib/types/client/waiting-copy.d.ts +10 -0
  33. package/lib/types/client/waiting-copy.d.ts.map +1 -0
  34. package/lib/types/client/waiting-copy.js +31 -0
  35. package/lib/types/client/waiting-copy.js.map +1 -0
  36. package/lib/types/compress.d.ts +31 -0
  37. package/lib/types/compress.d.ts.map +1 -0
  38. package/lib/types/compress.js +265 -0
  39. package/lib/types/compress.js.map +1 -0
  40. package/lib/types/controller.d.ts +31 -0
  41. package/lib/types/controller.d.ts.map +1 -0
  42. package/lib/types/controller.js +323 -0
  43. package/lib/types/controller.js.map +1 -0
  44. package/lib/types/dsh-compact-saviour.d.ts +10 -0
  45. package/lib/types/dsh-compact-saviour.d.ts.map +1 -0
  46. package/lib/types/dsh-compact-saviour.js +104 -0
  47. package/lib/types/dsh-compact-saviour.js.map +1 -0
  48. package/lib/types/message-source.d.ts +9 -0
  49. package/lib/types/message-source.d.ts.map +1 -0
  50. package/lib/types/message-source.js +2 -0
  51. package/lib/types/message-source.js.map +1 -0
  52. package/lib/types/pressure.d.ts +17 -0
  53. package/lib/types/pressure.d.ts.map +1 -0
  54. package/lib/types/pressure.js +71 -0
  55. package/lib/types/pressure.js.map +1 -0
  56. package/lib/types/shared.d.ts +42 -0
  57. package/lib/types/shared.d.ts.map +1 -0
  58. package/lib/types/shared.js +2 -0
  59. package/lib/types/shared.js.map +1 -0
  60. package/lib/types/summary-types.d.ts +20 -0
  61. package/lib/types/summary-types.d.ts.map +1 -0
  62. package/lib/types/summary-types.js +7 -0
  63. package/lib/types/summary-types.js.map +1 -0
  64. package/lib/types/vendor/region.d.ts +65 -0
  65. package/lib/types/vendor/region.d.ts.map +1 -0
  66. package/lib/types/vendor/region.js +412 -0
  67. package/lib/types/vendor/region.js.map +1 -0
  68. package/package.json +101 -0
  69. package/scripts/link-harness-dependencies.mjs +41 -0
  70. package/scripts/normalize-artifacts.mjs +7 -0
  71. package/src/client/assets.d.ts +1 -0
  72. package/src/client/context-elements.ts +39 -0
  73. package/src/client/index.tsx +214 -0
  74. package/src/client/meter-bridge.ts +65 -0
  75. package/src/client/ring-control.ts +134 -0
  76. package/src/client/satellite.ts +121 -0
  77. package/src/client/style.css +54 -0
  78. package/src/client/vendor/LICENSE +21 -0
  79. package/src/client/vendor/open-bot-motion.cjs +1673 -0
  80. package/src/client/vendor/open-bot-motion.d.cts +9 -0
  81. package/src/client/waiting-copy.tsx +29 -0
  82. package/src/compress.ts +210 -0
  83. package/src/controller.ts +248 -0
  84. package/src/dsh-compact-saviour.ts +82 -0
  85. package/src/message-source.ts +4 -0
  86. package/src/pressure.ts +69 -0
  87. package/src/shared.ts +13 -0
  88. package/src/summary-types.ts +11 -0
  89. package/src/vendor/region.ts +583 -0
  90. package/tests/compression.test.ts +130 -0
  91. package/tests/controller.test.ts +279 -0
  92. package/tests/interaction-fixture.tsx +38 -0
  93. package/tests/interaction.browser.mjs +201 -0
  94. package/tests/meter-bridge.browser.mjs +83 -0
  95. package/tests/motion.test.ts +26 -0
  96. package/tests/peer-range.test.ts +23 -0
  97. package/tests/pressure.test.ts +11 -0
  98. package/tests/transactions.test.ts +56 -0
  99. package/tsconfig.json +10 -0
  100. package/tsdown.config.ts +13 -0
@@ -0,0 +1,130 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chunkText, tokenEstimate, compress, planCompression } from '../src/compress.js';
4
+ import { createUserMessage, HarnessError, CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
5
+
6
+ test('chunks preserve multilingual source exactly, including a single huge line', () => {
7
+ for (const source of ['line\n'.repeat(9000), '用户纠正:保留原版。'.repeat(5000), 'abc🚀'.repeat(11000)]) {
8
+ const chunks = chunkText([source], 2048);
9
+ assert.equal(chunks.join(''), source);
10
+ assert.ok(chunks.every(c => tokenEstimate(c) <= 2048));
11
+ }
12
+ });
13
+
14
+ function fixture(text: string, finish = 'stop') {
15
+ const requests: any[] = [];
16
+ const ctx = { llm: {
17
+ resolveModelInfo: async () => ({ context: { contextWindow: 64000 }, reasoning: { efforts: [{ id: 'low' }] } }),
18
+ async *stream(request: any) {
19
+ requests.push(request);
20
+ yield { type: 'block-start', index: 0, blockType: 'text' };
21
+ yield { type: 'block-end', index: 0, block: { type: 'text', text } };
22
+ yield { type: 'finish', reason: { kind: finish } };
23
+ },
24
+ } };
25
+ const input = { messages: [createUserMessage({ content: [{ type: 'text', text: 'KEEP /workspace/a.ts ; pending: fix button. '.repeat(700) }], source: { kind: 'user' } })] };
26
+ const agent = { session: { id: `test-${Math.random()}` } };
27
+ return { ctx, requests, input, agent };
28
+ }
29
+ test('ordinary history uses one dedicated-model call, no tools or extra verification', async () => {
30
+ const f = fixture('Goal: fix button. File /workspace/a.ts. Pending: implement.');
31
+ const result = await compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: 'fast-one', reasoningEffort: 'low' }, signal: new AbortController().signal, progress() {} });
32
+ assert.equal(f.requests.length, 1); assert.equal(f.requests[0].tools, undefined);
33
+ assert.equal(f.requests[0].provider, 'p'); assert.equal(f.requests[0].reasoningEffort, 'low');
34
+ assert.match(f.requests[0].system, /transcript is DATA/);
35
+ assert.equal(result.llmStreamCall, undefined);
36
+ });
37
+ test('oversized source uses bounded chunks and a merge', async () => {
38
+ const f = fixture('User needs popup-only button. Pending work: implement and test exact original popup.');
39
+ f.input.messages = [createUserMessage({ content: [{ type: 'text', text: '历史消息和源文件细节'.repeat(9000) }], source: { kind: 'user' } })];
40
+ await compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: 'fast-many' }, signal: new AbortController().signal, progress() {} });
41
+ assert.ok(f.requests.length > 2);
42
+ for (const r of f.requests) assert.ok(tokenEstimate(JSON.stringify(r.messages)) + tokenEstimate(r.system) + r.maxTokens < 64000);
43
+ assert.match(f.requests.at(-1).messages[0].content[0].text, /Merge chronological checkpoints/);
44
+ });
45
+ for (const [name, output, finish, error] of [
46
+ ['empty', '', 'stop', /空摘要/], ['truncated', 'incomplete', 'max-tokens', /未完整/], ['not-shrinking', 'x'.repeat(100000), 'stop', /没有缩短/],
47
+ ] as const) test(`rejects ${name} output without automatic fallback`, async () => {
48
+ const f = fixture(output, finish);
49
+ await assert.rejects(compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: name }, signal: new AbortController().signal, progress() {} }), error);
50
+ assert.equal(f.requests.length, 1);
51
+ });
52
+ test('already cancelled request never calls a model', async () => {
53
+ const f = fixture('checkpoint'); const abort = new AbortController(); abort.abort();
54
+ await assert.rejects(compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: 'cancelled' }, signal: abort.signal, progress() {} }));
55
+ assert.equal(f.requests.length, 0);
56
+ });
57
+ test('a longer Chinese intermediate checkpoint is merged instead of rejected by its style target', async () => {
58
+ const f = fixture('保留事实'.repeat(550)); // 4,400 conservative tokens, above the 2,200-token style target.
59
+ f.input.messages = [createUserMessage({ content: [{ type: 'text', text: '历史消息和源文件细节'.repeat(5000) }], source: { kind: 'user' } })];
60
+ await compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: 'chinese-chunks' }, signal: new AbortController().signal, maxSummaryTokens: 6000, progress() {} });
61
+ assert.ok(f.requests.length > 2);
62
+ assert.match(f.requests.at(-1).messages[0].content[0].text, /Merge chronological checkpoints/);
63
+ });
64
+ test('final checkpoint still cannot exceed the actual working-model space', async () => {
65
+ const f = fixture('保留事实'.repeat(550));
66
+ await assert.rejects(compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: 'hard-limit' }, signal: new AbortController().signal, maxSummaryTokens: 3000, progress() {} }), /目标模型的可用空间/);
67
+ assert.equal(f.requests.length, 1);
68
+ });
69
+ test('same large input fits one 1M call and splits only for the smaller exact route', async () => {
70
+ const f = fixture('Goal: preserve user constraints. Pending: verify exact files and next action.');
71
+ f.input.messages = [createUserMessage({ content: [{ type: 'text', text: 'history '.repeat(60_000) }], source: { kind: 'user' } })];
72
+ const info = (capacity: number) => ({ provider: 'p', id: 'adaptive-large', name: 'adaptive-large', context: { contextWindow: capacity }, defaultMaxTokens: 8192 });
73
+ const large = planCompression(info(1_000_000), f.input), small = planCompression(info(64_000), f.input);
74
+ assert.equal(large.chunks.length, 1);
75
+ assert.ok(large.inputBudget > 800_000);
76
+ assert.ok(small.chunks.length > 1);
77
+ assert.ok(small.chunks.every(text => tokenEstimate(text) <= small.inputBudget));
78
+ f.ctx.llm.resolveModelInfo = async () => info(1_000_000) as never;
79
+ const progress: string[] = [];
80
+ await compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: 'adaptive-large' }, signal: new AbortController().signal, progress(_done, _total, message) { progress.push(message!); } });
81
+ assert.equal(f.requests.length, 1);
82
+ assert.ok(tokenEstimate(f.requests[0].messages[0].content[0].text) > 100_000);
83
+ assert.match(progress[0]!, /整段压缩/);
84
+ });
85
+ test('chunk budget accounts for model-specific output reserve and missing capacity fails before a call', async () => {
86
+ const f = fixture('checkpoint');
87
+ const info = { provider: 'p', id: 'budget', name: 'budget', context: { contextWindow: 64000 } };
88
+ const shortOutput = planCompression({ ...info, defaultMaxTokens: 2048 }, f.input);
89
+ const longOutput = planCompression({ ...info, defaultMaxTokens: 8192 }, f.input);
90
+ assert.equal(shortOutput.inputBudget - longOutput.inputBudget, 6144);
91
+ assert.throws(() => planCompression({ ...info, context: undefined }, f.input), /可确认的上下文容量/);
92
+ });
93
+ for (const delivery of ['thrown', 'terminal'] as const) test(`provider ${delivery} context rejection resizes only the rejected input and merges`, async () => {
94
+ const f = fixture('User goal: keep original popup. Pending work: implement and verify.');
95
+ const original = f.ctx.llm.stream;
96
+ f.ctx.llm.stream = async function* (request: any) {
97
+ if (tokenEstimate(request.messages[0].content[0].text) > 6500) {
98
+ f.requests.push(request);
99
+ if (delivery === 'thrown') throw new HarnessError('wrapped request failure', 'UPSTREAM', { cause: new HarnessError('actual context bound', CONTEXT_WINDOW_EXCEEDED_CODE) });
100
+ yield { type: 'finish', reason: { kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE, message: 'actual context bound' } } } as never;
101
+ return;
102
+ }
103
+ yield* original(request);
104
+ };
105
+ const before = JSON.stringify(f.input);
106
+ const result = await compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: `resize-${delivery}` }, signal: new AbortController().signal, progress() {} });
107
+ assert.ok(f.requests.length >= 4 && f.requests.length <= 5);
108
+ assert.ok(f.requests.slice(1).every(r => tokenEstimate(r.messages[0].content[0].text) <= 6500));
109
+ assert.match(f.requests.at(-1).messages[0].content[0].text, /Merge chronological checkpoints/);
110
+ assert.ok(result.summary.length);
111
+ assert.equal(JSON.stringify(f.input), before);
112
+ });
113
+ for (const code of ['QUOTA', 'RATE_LIMIT', 'TIMEOUT']) test(`${code} never turns into smaller billable requests`, async () => {
114
+ const f = fixture('checkpoint');
115
+ f.ctx.llm.stream = async function* (request: any) {
116
+ f.requests.push(request);
117
+ yield { type: 'finish', reason: { kind: 'error', failure: { code, message: 'provider cannot serve this context' } } } as never;
118
+ };
119
+ await assert.rejects(compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: code }, signal: new AbortController().signal, progress() {} }), (error: any) => error.code === code);
120
+ assert.equal(f.requests.length, 1);
121
+ });
122
+ test('persistent context rejection stops after bounded subdivisions', async () => {
123
+ const f = fixture('checkpoint');
124
+ f.ctx.llm.stream = async function* (request: any) {
125
+ f.requests.push(request);
126
+ throw new HarnessError('wrong context metadata', CONTEXT_WINDOW_EXCEEDED_CODE);
127
+ };
128
+ await assert.rejects(compress(f.ctx as never, f.input, f.agent as never, { target: { provider: 'p', model: 'always-overflow' }, signal: new AbortController().signal, progress() {} }), (error: any) => error.code === CONTEXT_WINDOW_EXCEEDED_CODE);
129
+ assert.ok(f.requests.length <= 5);
130
+ });
@@ -0,0 +1,279 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Context } from '@deepseek-ai/cordis';
4
+ import Loader from '@deepseek-ai/cordis-plugin-loader';
5
+ import AgentLoop from '@deepseek-ai/dsh-agent-loop';
6
+ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit';
7
+ import TokenMeter from '@deepseek-ai/dsh-token-meter';
8
+ import Basic from '@deepseek-ai/dsh-compaction-basic';
9
+ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm';
10
+ import { SessionId } from '@deepseek-ai/dsh-session';
11
+ import { Controller } from '../src/controller.js';
12
+ import { Config } from '../src/dsh-compact-saviour.js';
13
+ import { selectCompactableRange } from '../src/vendor/region.js';
14
+
15
+ class Adapter extends LlmAdapter {
16
+ compactModels: string[] = [];
17
+ failRescue = false;
18
+ failWorking = true;
19
+ gate?: Promise<void>;
20
+ usageInputTokens?: number;
21
+ async resolveModel(provider: string, model: string) { return { provider, id: model, name: model, context: { contextWindow: 100000 } }; }
22
+ async *stream(request: any) {
23
+ if (request.purpose === 'compaction') {
24
+ this.compactModels.push(request.model);
25
+ if (request.model === 'working' && this.failWorking) throw new Error('working model quota exhausted');
26
+ if (request.model === 'fast' && this.failRescue) throw new Error('rescue model unavailable');
27
+ }
28
+ if (request.purpose !== 'compaction' && this.gate) await this.gate;
29
+ yield { type: 'block-start' as const, index: 0, blockType: 'text' as const };
30
+ yield { type: 'block-end' as const, index: 0, block: { type: 'text' as const, text: request.purpose === 'compaction' ? 'User needs popup-only manual compression. Preserve original UI. Next: verify.' : 'done' } };
31
+ if (request.purpose !== 'compaction' && this.usageInputTokens) yield { type: 'usage' as const, usage: { inputTokens: this.usageInputTokens, outputTokens: 1 } };
32
+ yield { type: 'finish' as const, reason: { kind: 'stop' as const } };
33
+ }
34
+ }
35
+ async function fixture(usageInputTokens?: number, mode: 'direct' | 'rescue' | 'legacy' = 'rescue', enabled = true) {
36
+ const ctx = new Context(); await mountAgentLoopTestDependencies(ctx);
37
+ await ctx.plugin(AgentLoop, { agents: [] }); await ctx.plugin(TokenMeter); await ctx.plugin(Loader);
38
+ ctx.loader.internal = { version: 'v2', async import(name: string) { if (name === '@deepseek-ai/dsh-compaction-basic') return Basic; throw new Error(name); } } as never;
39
+ const adapter = new Adapter(); ctx.llm.registerAdapter(['mock'], adapter);
40
+ adapter.usageInputTokens = usageInputTokens;
41
+ const agent = await ctx.agentLoop.create(SessionId(`cs-controller-${Math.random()}`), { provider: 'mock', model: 'working' });
42
+ await agent.ctx.plugin({ inject: ['loader'], async apply(child: Context) {
43
+ await child.loader.create({ id: 'compaction-basic', name: '@deepseek-ai/dsh-compaction-basic', config: { auto: false } });
44
+ } });
45
+ await ctx.loader.await();
46
+ let controller!: Controller;
47
+ const owner = ctx.plugin({ name: 'test-saviour-owner', inject: ['agents', 'llm', 'tokenMeter', 'sessionProjections'], apply(child: Context) {
48
+ controller = new Controller(child, { get: () => ({ enabled, ...(mode === 'legacy' ? {} : { mode }), provider: 'mock', model: 'fast', reasoningEffort: '' }) } as never);
49
+ } });
50
+ await owner;
51
+ for (const text of ['history facts '.repeat(1200), 'last request '.repeat(400)]) {
52
+ agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })); await agent.whenIdle();
53
+ }
54
+ return { ctx, agent, adapter, controller, owner };
55
+ }
56
+ test('fresh and missing-mode settings default to rescue; explicit direct remains opt-in', () => {
57
+ assert.equal(Config({}).mode.get(), 'rescue');
58
+ assert.equal(Config({ provider: 'mock', model: 'fast' }).mode.get(), 'rescue');
59
+ assert.equal(Config({ mode: 'direct', provider: 'mock', model: 'fast' }).mode.get(), 'direct');
60
+ });
61
+ for (const mode of ['rescue', 'legacy'] as const) test(`real Loader engine (${mode}): first failure stays official; second failure invokes dedicated rescue once`, async () => {
62
+ const { ctx, agent, adapter, controller } = await fixture(undefined, mode);
63
+ try {
64
+ assert.equal(controller.state(agent.session.id).available, true);
65
+ let tested = false;
66
+ agent.ctx.on('agent/pre-step', async ({ signal }, next) => {
67
+ if (!tested) {
68
+ tested = true;
69
+ const range = selectCompactableRange(agent.session, ctx.tokenMeter.measure(agent.session), 0)!;
70
+ await assert.rejects(agent.ctx.get('compaction')!.compactRegion(range.start, range.end, agent, signal));
71
+ assert.deepEqual(adapter.compactModels, ['working']);
72
+ assert.equal(controller.state(agent.session.id).job.failures, 1);
73
+ const result = await agent.ctx.get('compaction')!.compactRegion(range.start, range.end, agent, signal);
74
+ assert.ok(result.summarySeq);
75
+ }
76
+ return next();
77
+ });
78
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Continue.' }], source: { kind: 'user' } })); await agent.whenIdle();
79
+ assert.ok(tested); assert.deepEqual(adapter.compactModels, ['working', 'working', 'fast']);
80
+ assert.equal(controller.state(agent.session.id).job.phase, 'done');
81
+ assert.equal(controller.state(agent.session.id).job.failures, 0);
82
+ assert.equal(agent.session.requestHeader()?.config.model, 'working', 'automatic rescue keeps the conversation model');
83
+ } finally { await ctx.fiber.dispose(); }
84
+ });
85
+ test('manual request reserves idle maintenance and leaves conversation model unchanged', async () => {
86
+ const { ctx, agent, adapter, controller } = await fixture();
87
+ try {
88
+ controller.request(agent.session.id, 'configured');
89
+ for (let i = 0; i < 100 && ['queued', 'running'].includes(controller.state(agent.session.id).job.phase); i++) await new Promise(r => setTimeout(r, 5));
90
+ assert.equal(controller.state(agent.session.id).job.phase, 'done');
91
+ assert.deepEqual(adapter.compactModels, ['fast']);
92
+ assert.equal(agent.session.requestHeader()?.config.model, 'working');
93
+ } finally { await ctx.fiber.dispose(); }
94
+ });
95
+ test('unload removes only owned wrappers and restores official methods', async () => {
96
+ const { ctx, agent, owner } = await fixture();
97
+ try {
98
+ const engine = agent.ctx.get('compaction')!;
99
+ assert.ok(Object.getOwnPropertyDescriptor(engine, 'compactRegion'));
100
+ assert.ok(Object.getOwnPropertyDescriptor(ctx.tokenMeter, 'measure'));
101
+ await owner.dispose();
102
+ assert.equal(Object.getOwnPropertyDescriptor(engine, 'compactRegion'), undefined);
103
+ assert.equal(Object.getOwnPropertyDescriptor(engine, 'compactIfNeeded'), undefined);
104
+ assert.equal(Object.getOwnPropertyDescriptor(engine, 'compactNow'), undefined);
105
+ assert.equal(Object.getOwnPropertyDescriptor(ctx.tokenMeter, 'measure'), undefined);
106
+ } finally { await ctx.fiber.dispose(); }
107
+ });
108
+ test('impossible usage is corrected in the public meter without rewriting history or billing', async () => {
109
+ const { ctx, agent, controller, owner } = await fixture(8_000_000);
110
+ try {
111
+ const events = JSON.stringify(agent.session.snapshotEvents());
112
+ const billing = structuredClone(ctx.sessionProjections.stateOf(agent.session, 'tokenUsage'));
113
+ const view = await controller.context(agent.session.id);
114
+ assert.equal(view?.corrected, true);
115
+ assert.equal(view?.reason, 'invalid-usage');
116
+ assert.ok(view!.reportedTokens >= 8_000_000);
117
+ assert.ok(view!.usedTokens < 10_000);
118
+ assert.equal(ctx.tokenMeter.measure(agent.session).totalTokens, view!.usedTokens);
119
+ assert.equal(JSON.stringify(agent.session.snapshotEvents()), events);
120
+ assert.deepEqual(ctx.sessionProjections.stateOf(agent.session, 'tokenUsage'), billing);
121
+ await owner.dispose();
122
+ assert.ok(ctx.tokenMeter.measure(agent.session).totalTokens >= 8_000_000);
123
+ } finally { await ctx.fiber.dispose(); }
124
+ });
125
+ test('ordinary provider calibration remains authoritative even above the text estimate', async () => {
126
+ const { ctx, agent, controller } = await fixture(80_000);
127
+ try {
128
+ const view = await controller.context(agent.session.id);
129
+ assert.equal(view?.corrected, false);
130
+ assert.ok(view!.usedTokens >= 80_000);
131
+ assert.equal(ctx.tokenMeter.measure(agent.session).baseline.kind, 'usage');
132
+ } finally { await ctx.fiber.dispose(); }
133
+ });
134
+ test('corrected pressure avoids useless automatic calls; real context-overflow still compacts', async () => {
135
+ const { ctx, agent, adapter } = await fixture(8_000_000);
136
+ try {
137
+ let checked = false;
138
+ agent.ctx.on('agent/pre-step', async ({ signal }, next) => {
139
+ if (!checked) {
140
+ checked = true;
141
+ const engine = agent.ctx.get('compaction')!;
142
+ assert.equal(await engine.compactIfNeeded(agent, 'pressure', signal), null);
143
+ assert.deepEqual(adapter.compactModels, []);
144
+ adapter.failWorking = false;
145
+ const result = await engine.compactIfNeeded(agent, 'context-overflow', signal);
146
+ assert.ok(result);
147
+ assert.ok(adapter.compactModels.includes('working'));
148
+ }
149
+ return next();
150
+ });
151
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Continue.' }], source: { kind: 'user' } })); await agent.whenIdle();
152
+ assert.ok(checked);
153
+ assert.ok(adapter.compactModels.includes('working'));
154
+ } finally { await ctx.fiber.dispose(); }
155
+ });
156
+ test('inflated provider usage does not become fixed overhead or prevent manual rescue', async () => {
157
+ const { ctx, agent, adapter, controller } = await fixture(8_000_000);
158
+ try {
159
+ const before = ctx.tokenMeter.measure(agent.session);
160
+ assert.ok(before.totalTokens >= 8_000_000);
161
+ assert.ok(before.surfaceTokens < 10_000);
162
+ controller.request(agent.session.id, 'configured');
163
+ for (let i = 0; i < 100 && ['queued', 'running'].includes(controller.state(agent.session.id).job.phase); i++) await new Promise(r => setTimeout(r, 5));
164
+ const job = controller.state(agent.session.id).job;
165
+ assert.equal(job.phase, 'done', job.message);
166
+ assert.deepEqual(adapter.compactModels, ['fast']);
167
+ assert.equal(job.beforeTokens, before.surfaceTokens);
168
+ assert.ok(job.afterTokens! < job.beforeTokens!);
169
+ assert.equal(agent.session.requestHeader()?.config.model, 'working');
170
+ } finally { await ctx.fiber.dispose(); }
171
+ });
172
+ test('actual oversized tools still prevent a summary that cannot fit the working model', async () => {
173
+ const { ctx, agent, adapter, controller } = await fixture();
174
+ try {
175
+ agent.session.append('request/header', { reason: 'initial', header: {
176
+ ...agent.session.requestHeader()!, tools: [{ name: 'huge', description: 'schema '.repeat(50_000), parameters: { type: 'object' } }],
177
+ } });
178
+ controller.request(agent.session.id, 'configured');
179
+ for (let i = 0; i < 100 && ['queued', 'running'].includes(controller.state(agent.session.id).job.phase); i++) await new Promise(r => setTimeout(r, 5));
180
+ const job = controller.state(agent.session.id).job;
181
+ assert.equal(job.phase, 'failed');
182
+ assert.match(job.message, /工具定义和输出预留/);
183
+ assert.deepEqual(adapter.compactModels, []);
184
+ } finally { await ctx.fiber.dispose(); }
185
+ });
186
+ test('a manual click during a running step queues, then compacts at the safe boundary', async () => {
187
+ const { ctx, agent, adapter, controller } = await fixture();
188
+ try {
189
+ let release!: () => void; adapter.gate = new Promise(resolve => { release = resolve; });
190
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'One final response.' }], source: { kind: 'user' } }));
191
+ for (let i = 0; i < 100 && agent.status === 'idle'; i++) await new Promise(r => setTimeout(r, 2));
192
+ assert.equal(agent.status, 'running');
193
+ assert.equal(controller.request(agent.session.id, 'configured').job.phase, 'queued');
194
+ assert.equal(adapter.compactModels.length, 0);
195
+ release(); await agent.whenIdle();
196
+ for (let i = 0; i < 100 && ['queued', 'running'].includes(controller.state(agent.session.id).job.phase); i++) await new Promise(r => setTimeout(r, 5));
197
+ assert.equal(controller.state(agent.session.id).job.phase, 'done');
198
+ assert.deepEqual(adapter.compactModels, ['fast']);
199
+ } finally { await ctx.fiber.dispose(); }
200
+ });
201
+ test('rescue failure never switches models; explicit current-model retry is one attempt only', async () => {
202
+ const { ctx, agent, adapter, controller } = await fixture();
203
+ try {
204
+ adapter.failRescue = true;
205
+ controller.request(agent.session.id, 'configured');
206
+ for (let i = 0; i < 100 && ['queued', 'running'].includes(controller.state(agent.session.id).job.phase); i++) await new Promise(r => setTimeout(r, 5));
207
+ assert.equal(controller.state(agent.session.id).job.phase, 'failed');
208
+ assert.match(controller.state(agent.session.id).job.message, /rescue model unavailable/);
209
+ assert.deepEqual(adapter.compactModels, ['fast']);
210
+ adapter.failWorking = false;
211
+ controller.request(agent.session.id, 'current');
212
+ for (let i = 0; i < 100 && ['queued', 'running'].includes(controller.state(agent.session.id).job.phase); i++) await new Promise(r => setTimeout(r, 5));
213
+ assert.deepEqual(adapter.compactModels, ['fast', 'working']);
214
+ assert.equal(controller.state(agent.session.id).config.model, 'fast');
215
+ assert.equal(controller.state(agent.session.id).job.phase, 'done');
216
+ } finally { await ctx.fiber.dispose(); }
217
+ });
218
+
219
+ test('direct mode uses the dedicated model on the first automatic overflow, with a durable transaction', async () => {
220
+ const { ctx, agent, adapter, controller } = await fixture(undefined, 'direct');
221
+ try {
222
+ let checked = false;
223
+ agent.ctx.on('agent/pre-step', async ({ signal }, next) => {
224
+ if (!checked) {
225
+ checked = true;
226
+ const before = agent.session.surface.replaceGeneration;
227
+ const result = await agent.ctx.get('compaction')!.compactIfNeeded(agent, 'context-overflow', signal);
228
+ assert.ok(result?.summarySeq);
229
+ assert.ok(agent.session.surface.replaceGeneration > before);
230
+ assert.deepEqual(adapter.compactModels, ['fast']);
231
+ assert.equal(controller.state(agent.session.id).job.phase, 'done');
232
+ }
233
+ return next();
234
+ });
235
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Continue.' }], source: { kind: 'user' } })); await agent.whenIdle();
236
+ assert.ok(checked); assert.deepEqual(adapter.compactModels, ['fast']);
237
+ assert.equal(agent.session.requestHeader()?.config.model, 'working');
238
+ const events = agent.session.snapshotEvents();
239
+ assert.ok(events.some(e => e.type === 'compaction/summary'));
240
+ assert.ok(events.some(e => e.type === 'compaction/end' && !e.data.error));
241
+ } finally { await ctx.fiber.dispose(); }
242
+ });
243
+
244
+ for (const [mode, enabled] of [['rescue', true], ['direct', true], ['legacy', true], ['rescue', false]] as const) test(`native manual compactNow uses Saviour with ${mode}, automatic enabled=${enabled}`, async () => {
245
+ const { ctx, agent, adapter } = await fixture(undefined, mode, enabled);
246
+ try {
247
+ const result = await agent.ctx.get('compaction')!.compactNow(agent, new AbortController().signal, 'test-command' as never);
248
+ assert.ok(result?.summarySeq);
249
+ assert.deepEqual(adapter.compactModels, ['fast']);
250
+ const end = agent.session.snapshotEvents().find(e => e.type === 'compaction/end');
251
+ assert.equal((end?.data as any).sourceCommandId, 'test-command');
252
+ assert.equal(agent.session.requestHeader()?.config.model, 'working');
253
+ } finally { await ctx.fiber.dispose(); }
254
+ });
255
+
256
+ test('direct failure blocks automatic repeat calls until an explicit choice; cancellation releases the block', async () => {
257
+ const { ctx, agent, adapter, controller } = await fixture(undefined, 'direct');
258
+ try {
259
+ adapter.failRescue = true;
260
+ let checked = false;
261
+ agent.ctx.on('agent/pre-step', async ({ signal }, next) => {
262
+ if (!checked) {
263
+ checked = true;
264
+ const engine = agent.ctx.get('compaction')!;
265
+ const before = agent.session.surface.replaceGeneration;
266
+ await assert.rejects(engine.compactIfNeeded(agent, 'context-overflow', signal));
267
+ assert.equal(agent.session.surface.replaceGeneration, before);
268
+ assert.equal(await engine.compactIfNeeded(agent, 'context-overflow', signal), null);
269
+ assert.deepEqual(adapter.compactModels, ['fast']);
270
+ controller.request(agent.session.id, 'cancel');
271
+ adapter.failRescue = false;
272
+ assert.ok(await engine.compactIfNeeded(agent, 'context-overflow', signal));
273
+ }
274
+ return next();
275
+ });
276
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Continue.' }], source: { kind: 'user' } })); await agent.whenIdle();
277
+ assert.ok(checked); assert.deepEqual(adapter.compactModels, ['fast', 'fast']);
278
+ } finally { await ctx.fiber.dispose(); }
279
+ });
@@ -0,0 +1,38 @@
1
+ import React, { useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { flushSync, createPortal } from 'react-dom';
4
+ import { PopupBridge } from '../src/client/index.js';
5
+ import { Satellite } from '../src/client/satellite.js';
6
+ const fixture = window as any;
7
+ fixture.requests = [];
8
+ fixture.session = 'fixture-A';
9
+ fixture.result = 'running';
10
+ fixture.job = { phase: 'idle', message: '', failures: 0 };
11
+ fixture.apiError = false;
12
+ fixture.bridgeVisible = true;
13
+ fixture.fetch = async (_url: string, options?: RequestInit) => {
14
+ if (options?.method === 'POST') {
15
+ const body = JSON.parse(String(options.body)); fixture.requests.push(body);
16
+ if (fixture.apiError) return new Response(JSON.stringify({ error: '请选择压缩模型。' }), { status: 400 });
17
+ fixture.job = { phase: body.action === 'cancel' ? 'idle' : fixture.result, message: body.action === 'cancel' ? '已取消' : '正在压缩上下文…', failures: 0 };
18
+ }
19
+ return new Response(JSON.stringify({ config: { enabled: true, mode: 'direct', provider: 'mock', model: 'fast', reasoningEffort: '' }, available: true, job: fixture.job }));
20
+ };
21
+ fixture.layout = 'portal';
22
+ fixture.hasBreakdown = true;
23
+ fixture.showRing = true;
24
+ function Fixture() {
25
+ const [open, setOpen] = useState(false);
26
+ const panel = <div role="dialog" aria-label="上下文已用"><div className="header"><span>上下文已用</span><span>48%</span><span></span><span>~508K / 1M</span></div><div className="bar"><div/></div>{fixture.hasBreakdown && <dl><div><dt>系统提示词</dt><dd>~2.3K</dd></div><div><dt>工具定义</dt><dd>~34.1K</dd></div><div><dt>对话消息</dt><dd>~372K</dd></div></dl>}</div>;
27
+ const ring = fixture.showRing && <span><button id="ring" aria-label="上下文已用 48%" aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen(!open)}><svg viewBox="0 0 14 14" width="14" height="14"><circle cx="7" cy="7" r="5.5" fill="none" stroke="#e3e5e8" strokeWidth="2"/><circle cx="7" cy="7" r="5.5" fill="none" stroke="#9097a2" strokeWidth="2" strokeDasharray="16 35"/></svg>{fixture.layout === 'portal' && <span>48%</span>}</button>{open && (fixture.layout === 'portal' ? createPortal(panel, document.body) : panel)}</span>;
28
+ const controls = <><span data-slot="conversation.input.right">{fixture.bridgeVisible && <PopupBridge sessionId={fixture.session}/>}</span><button className="send" aria-label="发送">↑</button></>;
29
+ // RC2 moved ContextMeter outside data-composer-card and portals its panel to
30
+ // document.body; keep this fixture faithful to both independent changes.
31
+ return <div className="composer">{fixture.layout === 'portal' ? <><div data-composer-card><div><div>{controls}</div></div></div><div className="dock">{ring}</div></> : <>{ring}{controls}</>}</div>;
32
+
33
+ }
34
+ const root = createRoot(document.getElementById('root')!);
35
+ fixture.render = () => flushSync(() => root.render(<Fixture/>));
36
+ fixture.unmount = () => flushSync(() => root.render(null));
37
+ fixture.Satellite = Satellite;
38
+ fixture.render();