dsh-context-compression-improved 0.4.0-beta.1 → 0.5.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/CHANGELOG.ja.md +68 -36
- package/CHANGELOG.ko.md +67 -35
- package/CHANGELOG.md +195 -134
- package/CHANGELOG.zh.md +64 -36
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/installation.ja.md +2 -2
- package/docs/installation.ko.md +2 -2
- package/docs/installation.md +103 -78
- package/docs/installation.zh.md +100 -77
- package/docs/repair-log.md +54 -0
- package/package.json +1 -1
- package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
- package/packages/selector/lib/client.d.ts +7 -0
- package/packages/selector/lib/client.js +33 -3
- package/packages/selector/lib/index.d.ts +7 -0
- package/packages/selector/lib/index.js +112 -3
- package/packages/selector/lib/pruner.d.ts +128 -1
- package/packages/selector/lib/pruner.js +2802 -1374
- package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
- package/packages/selector/src/client/index.ts +1 -1
- package/packages/selector/src/client/preset-options.ts +2 -0
- package/packages/selector/src/index.ts +129 -49
- package/packages/selector/src/profiles.ts +48 -0
- package/packages/selector/src/pruner/content.ts +18 -5
- package/packages/selector/src/pruner/state.ts +3 -0
- package/packages/selector/src/pruner/types.ts +23 -5
- package/packages/selector/src/pruner.ts +297 -162
- package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
- package/packages/selector/src/runtime/audit.ts +40 -2
- package/packages/selector/src/runtime/config.ts +88 -1
- package/packages/selector/src/runtime/measurement.ts +31 -2
- package/packages/selector/src/runtime/reducers.ts +1115 -97
- package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
- package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
- package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
- package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
- package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
- package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
- package/packages/selector/src/runtime/toolclass.ts +103 -0
- package/packages/selector/src/runtime/types.ts +37 -0
- package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
- package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
- package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
- package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
- package/packages/selector/tests/runtime/audit.spec.ts +88 -1
- package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
- package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
- package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
- package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
- package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
- package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
- package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
- package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
- package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
- package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
- package/scripts/toolclass-corpus-replay.mjs +281 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { normalizeTerminalLines, normalizeTerminalText } from '../../src/runtime/reducers.ts'
|
|
3
|
+
|
|
4
|
+
describe('non-adjacent frequency folding (R11)', () => {
|
|
5
|
+
it('folds a line repeated 400 times but separated into first + one counted marker', () => {
|
|
6
|
+
// The adjacent fold only collapses CONSECUTIVE runs (0.03-0.32% of real
|
|
7
|
+
// duplicate content); separated repeats grew to 8.37% in large results.
|
|
8
|
+
const lines: string[] = ['start']
|
|
9
|
+
for (let index = 0; index < 400; index++) {
|
|
10
|
+
lines.push('same polling line', `interleaved ${String(index)}`)
|
|
11
|
+
}
|
|
12
|
+
lines.push('end')
|
|
13
|
+
const folded = normalizeTerminalLines(lines.join('\n')).folded
|
|
14
|
+
const texts = folded.map(line => line.text)
|
|
15
|
+
const sameCount = texts.filter(text => text === 'same polling line').length
|
|
16
|
+
expect(sameCount).toBe(1)
|
|
17
|
+
const marker = texts.find(text => text.startsWith('[×') && text.includes('same as line 2'))
|
|
18
|
+
expect(marker).toBeDefined()
|
|
19
|
+
expect(marker).toMatch(/\[× 400 total/)
|
|
20
|
+
// The marker cites the original-event lines it covers.
|
|
21
|
+
expect(marker).toMatch(/original lines? \d+/)
|
|
22
|
+
expect(texts[0]).toBe('start')
|
|
23
|
+
expect(texts.at(-1)).toBe('end')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('leaves pairs alone (below the fold threshold)', () => {
|
|
27
|
+
const folded = normalizeTerminalLines(['a', 'b', 'a', 'c'].join('\n')).folded
|
|
28
|
+
expect(folded.map(line => line.text)).toEqual(['a', 'b', 'a', 'c'])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('counter-proof: pure consecutive repeats stay byte-identical with adjacent-only folding', () => {
|
|
32
|
+
// Adjacent folding runs first; the frequency pass must not re-fold its
|
|
33
|
+
// output (double folding would corrupt the counts).
|
|
34
|
+
const input = ['run', 'run', 'run', 'run', 'other'].join('\n')
|
|
35
|
+
const folded = normalizeTerminalLines(input).folded
|
|
36
|
+
expect(folded.map(line => line.text)).toEqual([
|
|
37
|
+
'run',
|
|
38
|
+
'[previous line repeated 3 more times]',
|
|
39
|
+
'other',
|
|
40
|
+
])
|
|
41
|
+
expect(normalizeTerminalText(input)).toBe('run\n[previous line repeated 3 more times]\nother')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('long-string placeholder (R12)', () => {
|
|
46
|
+
it('replaces long base64, long hex, and UUIDs with length summaries', () => {
|
|
47
|
+
const base64 = 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpabcdeg'.repeat(9).slice(0, 300)
|
|
48
|
+
const hex = 'deadbeefcafebabe0123456789abcdef'.repeat(2)
|
|
49
|
+
const uuid = '123e4567-e89b-12d3-a456-426614174000'
|
|
50
|
+
const text = `token: ${base64}\nhash: ${hex}\nid: ${uuid}`
|
|
51
|
+
const folded = normalizeTerminalLines(text).folded
|
|
52
|
+
expect(folded[0]!.text).toContain(`[base64 300 chars: ${base64.slice(0, 16)}`)
|
|
53
|
+
expect(folded[1]!.text).toContain('[hex 64 chars: deadbeefcafebabe')
|
|
54
|
+
expect(folded[2]!.text).toContain('[uuid]')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('counter-proof: short strings are never replaced', () => {
|
|
58
|
+
const shortishBase64 = 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo='.repeat(5).slice(0, 150)
|
|
59
|
+
const shortHex = 'deadbeefcafebabe0123456789abcdef'
|
|
60
|
+
const text = `${shortishBase64}\n${shortHex}`
|
|
61
|
+
const joined = normalizeTerminalLines(text).folded.map(line => line.text).join('\n')
|
|
62
|
+
expect(joined).toContain(shortishBase64)
|
|
63
|
+
expect(joined).toContain(shortHex)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('keeps the line count and original line numbers intact', () => {
|
|
67
|
+
const base64 = 'x'.repeat(260)
|
|
68
|
+
const text = `before ${base64} after\nnext line`
|
|
69
|
+
const folded = normalizeTerminalLines(text).folded
|
|
70
|
+
expect(folded).toHaveLength(2)
|
|
71
|
+
expect(folded[0]!.originalLine).toBe(1)
|
|
72
|
+
expect(folded[1]!.originalLine).toBe(2)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { foldRepeatedHtmlBlocks, reduceFreshToolResult, type ReducerInput } from '../../src/runtime/reducers.ts'
|
|
3
|
+
|
|
4
|
+
const SOURCE_REF = 'session://s1/event/3'
|
|
5
|
+
|
|
6
|
+
function htmlInput(text: string, budgetChars = 2_000): ReducerInput {
|
|
7
|
+
return {
|
|
8
|
+
toolName: 'web_fetch',
|
|
9
|
+
argumentsText: '{"url":"https://example.com"}',
|
|
10
|
+
text,
|
|
11
|
+
budgetChars,
|
|
12
|
+
sourceRef: SOURCE_REF,
|
|
13
|
+
isError: false,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function samplePage(): string {
|
|
18
|
+
return [
|
|
19
|
+
'<!DOCTYPE html>',
|
|
20
|
+
'<html>',
|
|
21
|
+
'<head>',
|
|
22
|
+
'<meta charset="utf-8">',
|
|
23
|
+
'<script>window.tracking = "analytics payload ".repeat(200);</script>',
|
|
24
|
+
'<style>body { color: red; }</style>',
|
|
25
|
+
'</head>',
|
|
26
|
+
'<body>',
|
|
27
|
+
'<!-- navigation comment that must disappear -->',
|
|
28
|
+
'<div id="main" class="wrapper" style="color: blue" onclick="track()">',
|
|
29
|
+
'<h1>Pricing Overview</h1>',
|
|
30
|
+
'<p>The pricing page explains tiers, quotas, and the billing cycle for every plan we offer.</p>',
|
|
31
|
+
'<img src="data:image/png;base64,AAAA BillingBlob" alt="chart">',
|
|
32
|
+
'<a href="https://example.com/faq" title="FAQ">Read the <strong>FAQ</strong></a>',
|
|
33
|
+
'<table>',
|
|
34
|
+
'<tr><th>Plan</th><th>Quota</th></tr>',
|
|
35
|
+
'<tr><td>Free</td><td>5k/day</td></tr>',
|
|
36
|
+
'</table>',
|
|
37
|
+
'<h2>Enterprise Terms</h2>',
|
|
38
|
+
'<p>Enterprise customers sign a dedicated agreement with custom data-retention guarantees.</p>',
|
|
39
|
+
'</div>',
|
|
40
|
+
'</body>',
|
|
41
|
+
'</html>',
|
|
42
|
+
].join('\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('html slimming (R13)', () => {
|
|
46
|
+
it('keeps body paragraphs and headings; never lands on pi-head', () => {
|
|
47
|
+
const output = reduceFreshToolResult(htmlInput(samplePage()))
|
|
48
|
+
expect(output).not.toBeNull()
|
|
49
|
+
expect(output!.reducer).not.toBe('pi-head')
|
|
50
|
+
expect(output!.reducer).toMatch(/^html-/)
|
|
51
|
+
expect(output!.text).toContain('Pricing Overview')
|
|
52
|
+
expect(output!.text).toContain('billing cycle for every plan')
|
|
53
|
+
expect(output!.text).toContain('Enterprise Terms')
|
|
54
|
+
expect(output!.text).toContain('Enterprise customers sign a dedicated agreement')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('drops script bodies, style bodies, comments, and data URIs', () => {
|
|
58
|
+
const output = reduceFreshToolResult(htmlInput(samplePage()))
|
|
59
|
+
expect(output!.text).not.toContain('analytics payload')
|
|
60
|
+
expect(output!.text).not.toContain('color: red')
|
|
61
|
+
expect(output!.text).not.toContain('navigation comment')
|
|
62
|
+
expect(output!.text).not.toContain('data:image/png')
|
|
63
|
+
// Non-whitelisted attributes are stripped; href/alt/id survive (the R13
|
|
64
|
+
// whitelist is href/src/alt/title/id).
|
|
65
|
+
expect(output!.text).not.toContain('class="wrapper"')
|
|
66
|
+
expect(output!.text).not.toContain('style="color: blue"')
|
|
67
|
+
expect(output!.text).not.toContain('onclick=')
|
|
68
|
+
expect(output!.text).toContain('href="https://example.com/faq"')
|
|
69
|
+
expect(output!.text).toContain('alt="chart"')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('falls back to a tag-aware skeleton when slim output exceeds the budget', () => {
|
|
73
|
+
const big = samplePage().replace(
|
|
74
|
+
'<h2>Enterprise Terms</h2>',
|
|
75
|
+
['<h2>Enterprise Terms</h2>', ...Array.from({ length: 120 }, (_, i) => `<p>Section sentence ${String(i)} about contracts and retention windows.</p>`)].join('\n'),
|
|
76
|
+
)
|
|
77
|
+
const output = reduceFreshToolResult(htmlInput(big, 1_200))
|
|
78
|
+
expect(output).not.toBeNull()
|
|
79
|
+
expect(output!.reducer).toBe('html-skeleton')
|
|
80
|
+
expect(output!.text).toContain('<h1>Pricing Overview</h1>')
|
|
81
|
+
expect(output!.text).toContain('<h2>Enterprise Terms</h2>')
|
|
82
|
+
expect(output!.text).toMatch(/\[\.\.\. lines \d+-\d+ elided \(\d+ lines\)/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('misjudgment guard: TS generics and comparisons are not HTML', () => {
|
|
86
|
+
const code = [
|
|
87
|
+
'const map = new Map<string, number>()',
|
|
88
|
+
'if (a < b) {',
|
|
89
|
+
' return new Array<Array<string>>()',
|
|
90
|
+
'}',
|
|
91
|
+
'const cmp = x < table.length ? 1 : 0',
|
|
92
|
+
'export function f<T extends object>(arg: T): T {',
|
|
93
|
+
' return arg',
|
|
94
|
+
'}',
|
|
95
|
+
].join('\n')
|
|
96
|
+
const output = reduceFreshToolResult(htmlInput(code))
|
|
97
|
+
expect(output?.reducer ?? 'none').not.toMatch(/^html-/)
|
|
98
|
+
})
|
|
99
|
+
// R4 third level (C23–C25, S4): deterministic repeated-block folding.
|
|
100
|
+
// NOTE: byte-identical repeated blocks are ALREADY folded by the R11
|
|
101
|
+
// normalizer before the reducers run — so these cases use counter-variant
|
|
102
|
+
// blocks (byte-different, digit-normalized-identical), which only the HTML
|
|
103
|
+
// third level can catch.
|
|
104
|
+
describe('repeated-block folding (R4)', () => {
|
|
105
|
+
const section = (word: string): string => [
|
|
106
|
+
`<h2>Section ${word}</h2>`,
|
|
107
|
+
`<p>Section ${word} carries unique body copy for the reader.</p>`,
|
|
108
|
+
].join('\n')
|
|
109
|
+
|
|
110
|
+
it('folds a counter-variant repeated non-table block to a counted marker (C24)', () => {
|
|
111
|
+
// Counter-variant navs: byte-different lines (so the R11 normalizer fold
|
|
112
|
+
// leaves them alone) whose digit-normalized signature the HTML third
|
|
113
|
+
// level must catch.
|
|
114
|
+
const navBlock = (badge: number): readonly string[] => [
|
|
115
|
+
'<div class="nav">',
|
|
116
|
+
'<a href="/home">Home</a>',
|
|
117
|
+
`<span class="badge">${String(badge)}</span>`,
|
|
118
|
+
'</div>',
|
|
119
|
+
]
|
|
120
|
+
const slim = [
|
|
121
|
+
...navBlock(1).map((text, offset) => ({ text, index: offset })),
|
|
122
|
+
{ text: '<h2>Section Alpha</h2>', index: 4 },
|
|
123
|
+
{ text: '<p>Alpha body copy.</p>', index: 5 },
|
|
124
|
+
...navBlock(2).map((text, offset) => ({ text, index: 6 + offset })),
|
|
125
|
+
{ text: '<h2>Section Beta</h2>', index: 10 },
|
|
126
|
+
{ text: '<p>Beta body copy.</p>', index: 11 },
|
|
127
|
+
...navBlock(3).map((text, offset) => ({ text, index: 12 + offset })),
|
|
128
|
+
{ text: '<h2>Section Gamma</h2>', index: 16 },
|
|
129
|
+
{ text: '<p>Gamma body copy.</p>', index: 17 },
|
|
130
|
+
]
|
|
131
|
+
const folded = foldRepeatedHtmlBlocks(slim, index => index + 1)
|
|
132
|
+
const markers = folded.filter(entry => entry.marker === true)
|
|
133
|
+
expect(markers).toHaveLength(2)
|
|
134
|
+
expect(markers[0]!.text).toBe('[×3 repeated block, first at line 1]')
|
|
135
|
+
expect(markers[0]!.index).toBe(6)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('never folds table blocks (C25)', () => {
|
|
139
|
+
// Data-different tables: neither the R11 normalizer nor the HTML third
|
|
140
|
+
// level may fold them.
|
|
141
|
+
const tableBlock = (plan: string, quota: string, index: number): readonly { text: string, index: number }[] =>
|
|
142
|
+
[
|
|
143
|
+
'<table>',
|
|
144
|
+
'<tr><th>Plan</th><th>Quota</th></tr>',
|
|
145
|
+
`<tr><td>${plan}</td><td>${quota}</td></tr>`,
|
|
146
|
+
'</table>',
|
|
147
|
+
].map((text, offset) => ({ text, index: index + offset }))
|
|
148
|
+
const slim = [
|
|
149
|
+
...tableBlock('Free', '5k/day', 0),
|
|
150
|
+
{ text: '<h2>Alpha</h2>', index: 4 },
|
|
151
|
+
...tableBlock('Pro', '50k/day', 5),
|
|
152
|
+
{ text: '<h2>Beta</h2>', index: 9 },
|
|
153
|
+
...tableBlock('Max', '500k/day', 10),
|
|
154
|
+
{ text: '<h2>Gamma</h2>', index: 14 },
|
|
155
|
+
]
|
|
156
|
+
const folded = foldRepeatedHtmlBlocks(slim, currentIndex => currentIndex + 1)
|
|
157
|
+
expect(folded.filter(entry => entry.marker === true)).toHaveLength(0)
|
|
158
|
+
// Every table line survives the pass untouched.
|
|
159
|
+
expect(folded.filter(entry => entry.text === '<table>')).toHaveLength(3)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('does not fold blocks whose copy differs beyond digits (S4)', () => {
|
|
163
|
+
const block = (word: string, index: number): readonly { text: string, index: number }[] =>
|
|
164
|
+
['<div class="note">', `<span>${word}</span>`, '</div>']
|
|
165
|
+
.map((text, offset) => ({ text, index: index + offset }))
|
|
166
|
+
const same = [
|
|
167
|
+
...block('Alpha', 0), { text: '<hr>', index: 3 },
|
|
168
|
+
...block('Alpha', 4), { text: '<hr>', index: 7 },
|
|
169
|
+
...block('Alpha', 8),
|
|
170
|
+
]
|
|
171
|
+
expect(foldRepeatedHtmlBlocks(same, currentIndex => currentIndex + 1)
|
|
172
|
+
.filter(entry => entry.marker === true)).toHaveLength(2)
|
|
173
|
+
const different = [
|
|
174
|
+
...block('Alpha', 0), { text: '<hr>', index: 3 },
|
|
175
|
+
...block('Beta', 4), { text: '<hr>', index: 7 },
|
|
176
|
+
...block('Gamma', 8),
|
|
177
|
+
]
|
|
178
|
+
expect(foldRepeatedHtmlBlocks(different, currentIndex => currentIndex + 1)
|
|
179
|
+
.filter(entry => entry.marker === true)).toHaveLength(0)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('keeps header + separator row of a table in the skeleton stage (C23)', () => {
|
|
183
|
+
const rows = ['<table>', '<tr><th>Plan</th><th>Quota</th></tr>', '<tr><td>Free</td><td>5k/day</td></tr>',
|
|
184
|
+
'<tr><td>Pro</td><td>50k/day</td></tr>', '<tr><td>Max</td><td>500k/day</td></tr>', '</table>']
|
|
185
|
+
const page = ['<h1>Plans</h1>', rows.join('\n'), '<h2>Details</h2>',
|
|
186
|
+
...Array.from({ length: 60 }, (_, index) => `<p>Detail line ${String(index)} with distinct copy ${String(index * 7)}.</p>`)].join('\n')
|
|
187
|
+
const output = reduceFreshToolResult(htmlInput(page, 900))
|
|
188
|
+
expect(output).not.toBeNull()
|
|
189
|
+
expect(output!.reducer).toBe('html-skeleton')
|
|
190
|
+
expect(output!.text).toContain('<tr><th>Plan</th><th>Quota</th></tr>')
|
|
191
|
+
expect(output!.text).toContain('<tr><td>Free</td><td>5k/day</td></tr>')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('digit-normalized signature folds counter-only variants (S4, end-to-end)', () => {
|
|
195
|
+
const counterBlock = (step: number): string => [
|
|
196
|
+
'<div class="page">',
|
|
197
|
+
`<span>Item ${String(step * 3 + 1)}</span>`,
|
|
198
|
+
`<span>Item ${String(step * 3 + 2)}</span>`,
|
|
199
|
+
'</div>',
|
|
200
|
+
].join('\n')
|
|
201
|
+
const page = [
|
|
202
|
+
counterBlock(0), section('Alpha'),
|
|
203
|
+
counterBlock(1), section('Beta'),
|
|
204
|
+
counterBlock(2), section('Gamma'),
|
|
205
|
+
counterBlock(3),
|
|
206
|
+
].join('\n')
|
|
207
|
+
const output = reduceFreshToolResult(htmlInput(page, 400))
|
|
208
|
+
expect(output).not.toBeNull()
|
|
209
|
+
expect(output!.text).toContain('repeated block')
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
normalizeTerminalLines,
|
|
4
|
+
normalizeTerminalText,
|
|
5
|
+
} from '../../src/runtime/reducers.ts'
|
|
6
|
+
|
|
7
|
+
/** R9a drift harness: `retrieve` reads the ORIGINAL event, so every line
|
|
8
|
+
* number a reducer prints must point back into the original event, not into
|
|
9
|
+
* the normalized text. The assertions below pin EXACT original line numbers —
|
|
10
|
+
* removing the mapping (or off-by-oneing it) fails these cases, which is the
|
|
11
|
+
* reverse-proof the R9a acceptance asks for. */
|
|
12
|
+
|
|
13
|
+
describe('normalizeTerminalLines', () => {
|
|
14
|
+
it('keeps folded lines 1:1 with the original event through ANSI + \\r redraw + adjacent dups', () => {
|
|
15
|
+
// Original event, 1-based:
|
|
16
|
+
// 1 staging files... (ANSI-decorated)
|
|
17
|
+
// 2 progress 10%
|
|
18
|
+
// 3 progress 10% (adjacent dup)
|
|
19
|
+
// 4 progress 10% (adjacent dup)
|
|
20
|
+
// 5 Downloaded 100% (\r redraw of one physical line)
|
|
21
|
+
// 6 done (ANSI-decorated)
|
|
22
|
+
const original = [
|
|
23
|
+
'\u001B[32mstaging files...\u001B[0m',
|
|
24
|
+
'progress 10%',
|
|
25
|
+
'progress 10%',
|
|
26
|
+
'progress 10%',
|
|
27
|
+
'Downloaded 50%\rDownloaded 100%',
|
|
28
|
+
'\u001B[1mdone\u001B[0m',
|
|
29
|
+
].join('\n')
|
|
30
|
+
|
|
31
|
+
const { folded, text } = normalizeTerminalLines(original)
|
|
32
|
+
|
|
33
|
+
expect(folded).toEqual([
|
|
34
|
+
{ text: 'staging files...', content: 'staging files...', originalLine: 1 },
|
|
35
|
+
{ text: 'progress 10%', content: 'progress 10%', originalLine: 2 },
|
|
36
|
+
{
|
|
37
|
+
text: '[previous line repeated 2 more times]',
|
|
38
|
+
content: '[previous line repeated 2 more times]',
|
|
39
|
+
originalLine: 3,
|
|
40
|
+
originalLineEnd: 4,
|
|
41
|
+
},
|
|
42
|
+
{ text: 'Downloaded 100%', content: 'Downloaded 100%', originalLine: 5 },
|
|
43
|
+
{ text: 'done', content: 'done', originalLine: 6 },
|
|
44
|
+
])
|
|
45
|
+
// The folded text is byte-identical with the string API.
|
|
46
|
+
expect(text).toBe(normalizeTerminalText(original))
|
|
47
|
+
expect(text).toBe([
|
|
48
|
+
'staging files...',
|
|
49
|
+
'progress 10%',
|
|
50
|
+
'[previous line repeated 2 more times]',
|
|
51
|
+
'Downloaded 100%',
|
|
52
|
+
'done',
|
|
53
|
+
].join('\n'))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('maps every kept line back to the same original line through ANSI stripping alone', () => {
|
|
57
|
+
const original = ['\u001B[1malpha\u001B[0m', 'beta', '\u001B[31mgamma\u001B[0m'].join('\n')
|
|
58
|
+
const { folded } = normalizeTerminalLines(original)
|
|
59
|
+
expect(folded.map(line => [line.text, line.originalLine])).toEqual([
|
|
60
|
+
['alpha', 1],
|
|
61
|
+
['beta', 2],
|
|
62
|
+
['gamma', 3],
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('never changes the line count before folding (logical 1:1 original)', () => {
|
|
67
|
+
// \r redraws and ANSI escapes consume no newlines, so even a line that
|
|
68
|
+
// redraws many times stays one logical line. A trailing newline keeps its
|
|
69
|
+
// empty logical line (byte-compatibility with the string API).
|
|
70
|
+
const original = 'a\rb\rc\rd\nx\n\u001B[2Ky\n'
|
|
71
|
+
const { folded } = normalizeTerminalLines(original)
|
|
72
|
+
expect(folded.map(line => line.text)).toEqual(['d', 'x', 'y', ''])
|
|
73
|
+
expect(folded.map(line => line.originalLine)).toEqual([1, 2, 3, 4])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('pins the repeat-marker range to the occurrences it replaces', () => {
|
|
77
|
+
const original = ['head', 'same', 'same', 'same', 'same', 'tail'].join('\n')
|
|
78
|
+
const { folded } = normalizeTerminalLines(original)
|
|
79
|
+
expect(folded).toEqual([
|
|
80
|
+
{ text: 'head', content: 'head', originalLine: 1 },
|
|
81
|
+
{ text: 'same', content: 'same', originalLine: 2 },
|
|
82
|
+
{
|
|
83
|
+
text: '[previous line repeated 3 more times]',
|
|
84
|
+
content: '[previous line repeated 3 more times]',
|
|
85
|
+
originalLine: 3,
|
|
86
|
+
originalLineEnd: 5,
|
|
87
|
+
},
|
|
88
|
+
{ text: 'tail', content: 'tail', originalLine: 6 },
|
|
89
|
+
])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// GF-1 dual view (spec.md 「行号修复补丁 GF-1」): a block-detected read
|
|
93
|
+
// gutter is stripped on the CONTENT view only — the output view keeps the
|
|
94
|
+
// host's `N: ` prefixes byte-for-byte because they are the model's only
|
|
95
|
+
// inline locator into the original file, while form detection and the fold
|
|
96
|
+
// keys must not see them.
|
|
97
|
+
it('strips a detected read gutter on the content view and keeps it on the output view', () => {
|
|
98
|
+
const original = [
|
|
99
|
+
'1: export function alpha() {',
|
|
100
|
+
'2: return 1',
|
|
101
|
+
'3: }',
|
|
102
|
+
'4: ',
|
|
103
|
+
'5: export function beta() {',
|
|
104
|
+
'6: }',
|
|
105
|
+
].join('\n')
|
|
106
|
+
const { folded, text, contentText } = normalizeTerminalLines(original)
|
|
107
|
+
// Output view: the gutter is preserved verbatim.
|
|
108
|
+
expect(text).toBe(original)
|
|
109
|
+
// Content view: the gutter is gone.
|
|
110
|
+
expect(contentText).toBe([
|
|
111
|
+
'export function alpha() {',
|
|
112
|
+
' return 1',
|
|
113
|
+
'}',
|
|
114
|
+
'',
|
|
115
|
+
'export function beta() {',
|
|
116
|
+
'}',
|
|
117
|
+
].join('\n'))
|
|
118
|
+
// Every folded line still maps 1:1 into the original event.
|
|
119
|
+
expect(folded.map(line => line.originalLine)).toEqual([1, 2, 3, 4, 5, 6])
|
|
120
|
+
expect(folded.map(line => line.content)).toEqual([
|
|
121
|
+
'export function alpha() {',
|
|
122
|
+
' return 1',
|
|
123
|
+
'}',
|
|
124
|
+
'',
|
|
125
|
+
'export function beta() {',
|
|
126
|
+
'}',
|
|
127
|
+
])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('does not strip a stray time-of-day colon as a read gutter (block-level guard)', () => {
|
|
131
|
+
// Only 1 of 5 non-empty lines is gutter-shaped, and the "numbers" are not
|
|
132
|
+
// strictly increasing — the block guard must keep both views intact.
|
|
133
|
+
const original = ['meeting at 12:30 pm', 'standup at 9:15 am', 'retro at 4:45 pm', 'done'].join('\n')
|
|
134
|
+
const { text, contentText } = normalizeTerminalLines(original)
|
|
135
|
+
expect(text).toBe(original)
|
|
136
|
+
expect(contentText).toBe(original)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/** 口径对拍 (task_2.3): `splitLines` (reducers.ts) and `splitScannedLines`
|
|
141
|
+
* (retrieve.ts) must count lines identically — both split on '\n' and drop
|
|
142
|
+
* only the trailing empty element when the text ends with a newline. retrieve
|
|
143
|
+
* scans the raw event, reducers see normalized text; the R9a mapping above is
|
|
144
|
+
* what keeps the two line spaces reconcilable. The behavioral proof lives in
|
|
145
|
+
* the drift cases; this suite pins the reducer-side convention. */
|
|
146
|
+
describe('line-splitting conventions', () => {
|
|
147
|
+
it('reducer-side folded lines join back to the text splitLines would produce', () => {
|
|
148
|
+
const original = 'one\ntwo\nthree'
|
|
149
|
+
const { folded, text } = normalizeTerminalLines(original)
|
|
150
|
+
expect(text.split('\n')).toEqual(folded.map(line => line.text))
|
|
151
|
+
expect(folded).toHaveLength(3)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { codePointLength } from '../../src/runtime/config.ts'
|
|
3
|
+
import { reduceFreshToolResult, type ReducerInput } from '../../src/runtime/reducers.ts'
|
|
4
|
+
|
|
5
|
+
function input(overrides: Partial<ReducerInput> = {}): ReducerInput {
|
|
6
|
+
return {
|
|
7
|
+
toolName: 'mcp_remote_fetch',
|
|
8
|
+
argumentsText: '{}',
|
|
9
|
+
text: '',
|
|
10
|
+
budgetChars: 4_000,
|
|
11
|
+
sourceRef: 'session://s/event/1',
|
|
12
|
+
isError: false,
|
|
13
|
+
...overrides,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function markdownDoc(sections: number): string {
|
|
18
|
+
const parts: string[] = ['# User Guide', 'Intro paragraph with orientation prose.']
|
|
19
|
+
for (let index = 1; index <= sections; index++) {
|
|
20
|
+
parts.push(`## Section ${String(index)}: Installation`)
|
|
21
|
+
for (let line = 0; line < 12; line++) {
|
|
22
|
+
parts.push(`Section ${String(index)} line ${String(line)} with ordinary documentation prose content.`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return parts.join('\n')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('document skeleton (R8)', () => {
|
|
29
|
+
it('keeps every H1-H3 heading and at least one sentence per section', () => {
|
|
30
|
+
const output = reduceFreshToolResult(input({ text: markdownDoc(8) }))
|
|
31
|
+
expect(output).not.toBeNull()
|
|
32
|
+
expect(output!.reducer).toBe('doc-skeleton')
|
|
33
|
+
for (let index = 1; index <= 8; index++) {
|
|
34
|
+
expect(output!.text).toContain(`## Section ${String(index)}: Installation`)
|
|
35
|
+
// 每节 ≥1 句:the section's first line survives.
|
|
36
|
+
expect(output!.text).toContain(`Section ${String(index)} line 0`)
|
|
37
|
+
}
|
|
38
|
+
expect(output!.text).toContain('# User Guide')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('keeps the first two rows of every table block', () => {
|
|
42
|
+
const doc = [
|
|
43
|
+
'# Report',
|
|
44
|
+
'Intro.',
|
|
45
|
+
'## Usage',
|
|
46
|
+
'Usage intro.',
|
|
47
|
+
'## Tables',
|
|
48
|
+
'Tables intro.',
|
|
49
|
+
...Array.from({ length: 30 }, (_, i) => `Intro filler sentence ${String(i)} to make the section elidable.`),
|
|
50
|
+
'| Name | Quota |',
|
|
51
|
+
'| --- | --- |',
|
|
52
|
+
'| Free | 5k |',
|
|
53
|
+
'| Pro | 50k |',
|
|
54
|
+
...Array.from({ length: 30 }, (_, i) => `Closing filler sentence ${String(i)} to make the section elidable.`),
|
|
55
|
+
'Trailing prose line.',
|
|
56
|
+
].join('\n')
|
|
57
|
+
const output = reduceFreshToolResult(input({ text: doc, toolName: 'mcp_fetch' }))
|
|
58
|
+
expect(output!.reducer).toBe('doc-skeleton')
|
|
59
|
+
expect(output!.text).toContain('| Name | Quota |')
|
|
60
|
+
expect(output!.text).toContain('| --- | --- |')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('cites original-event line ranges in elision markers (R9 spec)', () => {
|
|
64
|
+
const output = reduceFreshToolResult(input({ text: markdownDoc(6) }))
|
|
65
|
+
expect(output).not.toBeNull()
|
|
66
|
+
// Every elision marker carries an original line range and a line count.
|
|
67
|
+
const markers = output!.text.match(/\[\.\.\. lines \d+-\d+ elided \(\d+ lines\)[^\]]*\]/g) ?? []
|
|
68
|
+
expect(markers.length).toBeGreaterThan(0)
|
|
69
|
+
for (const marker of markers) {
|
|
70
|
+
const [, start, end, count] = /\[\.\.\. lines (\d+)-(\d+) elided \((\d+) lines\)/.exec(marker) ?? []
|
|
71
|
+
expect(Number(end) - Number(start) + 1).toBe(Number(count))
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('misjudgment guard: logs and build output are not documents', () => {
|
|
76
|
+
// Realistic build/log output carries no Markdown heading lines at all —
|
|
77
|
+
// timestamps, INFO frames, and stack locations never match `^#\\s`.
|
|
78
|
+
const log = [
|
|
79
|
+
'INFO 2026-01-01 starting build',
|
|
80
|
+
'WARN 2026-01-01 deprecated flag --legacy',
|
|
81
|
+
'INFO compiling modules',
|
|
82
|
+
'ERROR TS2345: cannot find name elsewhere',
|
|
83
|
+
...Array.from({ length: 40 }, (_, i) => `at /pkg/src/module${String(i)}.ts:12:5`),
|
|
84
|
+
].join('\n')
|
|
85
|
+
const output = reduceFreshToolResult(input({ text: log, toolName: 'bash', argumentsText: '{"command":"pnpm build"}' }))
|
|
86
|
+
expect(output?.reducer).not.toBe('doc-skeleton')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('prose head+tail keep (R8b)', () => {
|
|
91
|
+
const prose = [
|
|
92
|
+
...Array.from({ length: 60 }, (_, i) => `Early paragraph ${String(i)} with orientation prose for the reader.`),
|
|
93
|
+
...Array.from({ length: 60 }, (_, i) => `Middle paragraph ${String(i)} carries ordinary filler content.`),
|
|
94
|
+
...Array.from({ length: 60 }, (_, i) => `Final paragraph ${String(i)} holds the decisive conclusion about quotas.`),
|
|
95
|
+
].join('\n')
|
|
96
|
+
|
|
97
|
+
it('keeps head AND tail non-empty with an elision marker in between', () => {
|
|
98
|
+
const output = reduceFreshToolResult(input({ text: prose }))
|
|
99
|
+
expect(output).not.toBeNull()
|
|
100
|
+
expect(output!.reducer).toBe('prose-keep')
|
|
101
|
+
const lines = output!.text.split('\n')
|
|
102
|
+
expect(lines[0]).toContain('Early paragraph 0')
|
|
103
|
+
expect(lines.at(-1)).toContain('Final paragraph 59')
|
|
104
|
+
expect(output!.text).toMatch(/\[\.\.\. lines \d+-\d+ elided \(\d+ lines\)/)
|
|
105
|
+
// The tail keeps conclusions the old head-only reducer dropped.
|
|
106
|
+
expect(output!.text).toContain('decisive conclusion')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('counter-proof: head-only retention provably loses the tail keyword', () => {
|
|
110
|
+
// The old landing spots for prose were head-only (pi-head) or a salience
|
|
111
|
+
// pass whose middle list is empty for prose. A head-only slice of B chars
|
|
112
|
+
// can never contain a keyword that starts after char B: the prose is
|
|
113
|
+
// ~11k chars and the decisive sentence sits in the last third, so any
|
|
114
|
+
// head-only output within a 4k budget drops it by construction.
|
|
115
|
+
const keywordIndex = prose.indexOf('decisive conclusion')
|
|
116
|
+
expect(codePointLength(prose)).toBeGreaterThan(2 * 4_000)
|
|
117
|
+
expect(keywordIndex).toBeGreaterThan(4_000)
|
|
118
|
+
// The R8b output keeps it anyway (also asserted above).
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('misjudgment guard: source code still stays off the prose path', () => {
|
|
122
|
+
const code = [
|
|
123
|
+
'import { createHash } from \'node:crypto\'',
|
|
124
|
+
'',
|
|
125
|
+
...Array.from({ length: 40 }, (_, i) => `export function handler${String(i)}(input: string): string {`),
|
|
126
|
+
...Array.from({ length: 40 }, (_, i) => ` return hash(input, ${String(i)})`),
|
|
127
|
+
'}',
|
|
128
|
+
].join('\n')
|
|
129
|
+
const output = reduceFreshToolResult(input({ text: code }))
|
|
130
|
+
expect(output?.reducer).not.toBe('prose-keep')
|
|
131
|
+
expect(output?.reducer).not.toBe('doc-skeleton')
|
|
132
|
+
})
|
|
133
|
+
})
|