Skip to content

Commit e580aa9

Browse files
Megan RoggeMegan Rogge
authored andcommitted
Address PR review feedback
- gitDiffFilter: actually accumulate context-line runs and collapse them - toolResultCompressor: disable failing filters for the rest of a pass; warn once - toolResultCompressor: preserve LanguageModelTextPart2 audience when rewriting - toolResultCompressor: rename byte/bytes -> char/chars in telemetry + docs - Add unit tests for ToolResultCompressorService.maybeCompress
1 parent 5a4e6db commit e580aa9

4 files changed

Lines changed: 202 additions & 26 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { describe, expect, it } from 'vitest';
7+
import type { IConfigurationService } from '../../../../platform/configuration/common/configurationService';
8+
import type { ILogService } from '../../../../platform/log/common/logService';
9+
import type { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
10+
import {
11+
LanguageModelDataPart,
12+
LanguageModelPartAudience,
13+
LanguageModelTextPart,
14+
LanguageModelTextPart2,
15+
LanguageModelToolResult,
16+
} from '../../../../vscodeTypes';
17+
import { IToolResultFilter, ToolResultCompressorService } from '../toolResultCompressor';
18+
19+
const TOOL = 'run_in_terminal';
20+
21+
function makeService(opts: {
22+
enabled: boolean;
23+
warnings?: string[];
24+
}): ToolResultCompressorService {
25+
const config = {
26+
getConfig: (_key: unknown) => opts.enabled,
27+
} as unknown as IConfigurationService;
28+
const telemetry = {
29+
sendMSFTTelemetryEvent: () => { /* noop */ },
30+
} as unknown as ITelemetryService;
31+
const log = {
32+
warn: (msg: string) => { opts.warnings?.push(msg); },
33+
} as unknown as ILogService;
34+
return new ToolResultCompressorService(config, telemetry, log);
35+
}
36+
37+
function longText(prefix: string): string {
38+
// Must exceed MIN_COMPRESSIBLE_LENGTH (80) so filters get a chance to run.
39+
return prefix + ' ' + 'x'.repeat(200);
40+
}
41+
42+
const replaceWithFooFilter: IToolResultFilter = {
43+
id: 'test.replaceWithFoo',
44+
toolNames: [TOOL],
45+
matches: () => true,
46+
apply: () => ({ text: 'foo', compressed: true }),
47+
};
48+
49+
describe('ToolResultCompressorService', () => {
50+
it('returns undefined when disabled', () => {
51+
const svc = makeService({ enabled: false });
52+
svc.registerFilter(replaceWithFooFilter);
53+
const result = new LanguageModelToolResult([new LanguageModelTextPart(longText('hello'))]);
54+
expect(svc.maybeCompress(TOOL, {}, result)).toBeUndefined();
55+
});
56+
57+
it('returns undefined when no filters registered', () => {
58+
const svc = makeService({ enabled: true });
59+
const result = new LanguageModelToolResult([new LanguageModelTextPart(longText('hello'))]);
60+
expect(svc.maybeCompress(TOOL, {}, result)).toBeUndefined();
61+
});
62+
63+
it('returns undefined when no filters match', () => {
64+
const svc = makeService({ enabled: true });
65+
svc.registerFilter({
66+
id: 'no-match',
67+
toolNames: [TOOL],
68+
matches: () => false,
69+
apply: () => ({ text: 'foo', compressed: true }),
70+
});
71+
const result = new LanguageModelToolResult([new LanguageModelTextPart(longText('hello'))]);
72+
expect(svc.maybeCompress(TOOL, {}, result)).toBeUndefined();
73+
});
74+
75+
it('disables a throwing filter for the rest of the pass and warns once', () => {
76+
const warnings: string[] = [];
77+
const svc = makeService({ enabled: true, warnings });
78+
let calls = 0;
79+
svc.registerFilter({
80+
id: 'thrower',
81+
toolNames: [TOOL],
82+
matches: () => true,
83+
apply: () => { calls++; throw new Error('boom'); },
84+
});
85+
svc.registerFilter(replaceWithFooFilter);
86+
const result = new LanguageModelToolResult([
87+
new LanguageModelTextPart(longText('a')),
88+
new LanguageModelTextPart(longText('b')),
89+
new LanguageModelTextPart(longText('c')),
90+
]);
91+
const out = svc.maybeCompress(TOOL, {}, result)!;
92+
expect(out).toBeDefined();
93+
// Throwing filter is invoked exactly once on the first text part, then disabled.
94+
expect(calls).toBe(1);
95+
expect(warnings.length).toBe(1);
96+
expect(warnings[0]).toContain('thrower');
97+
// The other filter still rewrites every text part.
98+
for (const part of out.content) {
99+
expect(part).toBeInstanceOf(LanguageModelTextPart);
100+
expect((part as LanguageModelTextPart).value).toBe('foo');
101+
}
102+
});
103+
104+
it('preserves non-text parts unchanged', () => {
105+
const svc = makeService({ enabled: true });
106+
svc.registerFilter(replaceWithFooFilter);
107+
const dataPart = new LanguageModelDataPart(new Uint8Array([1, 2, 3]), 'application/octet-stream');
108+
const textPart = new LanguageModelTextPart(longText('hello'));
109+
const result = new LanguageModelToolResult([dataPart, textPart]);
110+
const out = svc.maybeCompress(TOOL, {}, result)!;
111+
expect(out.content[0]).toBe(dataPart);
112+
expect(out.content[1]).toBeInstanceOf(LanguageModelTextPart);
113+
expect((out.content[1] as LanguageModelTextPart).value).toBe('foo');
114+
});
115+
116+
it('preserves LanguageModelTextPart2 audience metadata when rewriting', () => {
117+
const svc = makeService({ enabled: true });
118+
svc.registerFilter(replaceWithFooFilter);
119+
const audience = [LanguageModelPartAudience.Assistant, LanguageModelPartAudience.User];
120+
const part = new LanguageModelTextPart2(longText('hello'), audience);
121+
const result = new LanguageModelToolResult([part]);
122+
const out = svc.maybeCompress(TOOL, {}, result)!;
123+
expect(out.content[0]).toBeInstanceOf(LanguageModelTextPart2);
124+
const rewritten = out.content[0] as LanguageModelTextPart2;
125+
expect(rewritten.value).toBe('foo');
126+
expect(rewritten.audience).toEqual(audience);
127+
});
128+
129+
it('skips text parts shorter than the minimum compressible length', () => {
130+
const svc = makeService({ enabled: true });
131+
svc.registerFilter(replaceWithFooFilter);
132+
const result = new LanguageModelToolResult([new LanguageModelTextPart('tiny')]);
133+
// Nothing was compressed because the part was below the threshold.
134+
expect(svc.maybeCompress(TOOL, {}, result)).toBeUndefined();
135+
});
136+
});

src/extension/tools/common/toolResultCompressor.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { ConfigKey, IConfigurationService } from '../../../platform/configuratio
88
import { ILogService } from '../../../platform/log/common/logService';
99
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
1010
import { createServiceIdentifier } from '../../../util/common/services';
11-
import { LanguageModelTextPart } from '../../../vscodeTypes';
11+
import { LanguageModelTextPart, LanguageModelTextPart2 } from '../../../vscodeTypes';
1212

1313
export const IToolResultCompressor = createServiceIdentifier<IToolResultCompressor>('IToolResultCompressor');
1414

@@ -54,8 +54,9 @@ export interface IToolResultCompressor {
5454
}
5555

5656
/**
57-
* Outputs at or below this size are not worth compressing.
58-
* Mirrors ztk's 80-byte minimum.
57+
* Outputs at or below this many characters (UTF-16 code units, i.e.
58+
* `string.length`) are not worth compressing. Mirrors ztk's 80-character
59+
* minimum.
5960
*/
6061
const MIN_COMPRESSIBLE_LENGTH = 80;
6162

@@ -96,6 +97,11 @@ export class ToolResultCompressorService implements IToolResultCompressor {
9697
return undefined;
9798
}
9899

100+
// Mutable copy: filters that throw get spliced out so we don't repeatedly
101+
// invoke a broken filter on every subsequent text part in this pass.
102+
const activeFilters = matchingFilters.slice();
103+
const disabledFilterIds = new Set<string>();
104+
99105
let totalBefore = 0;
100106
let totalAfter = 0;
101107
let anyCompressed = false;
@@ -111,23 +117,35 @@ export class ToolResultCompressorService implements IToolResultCompressor {
111117
}
112118

113119
let current = original;
114-
for (const filter of matchingFilters) {
120+
for (let i = 0; i < activeFilters.length; /* manual increment */) {
121+
const filter = activeFilters[i];
115122
try {
116123
const out = filter.apply(current, input);
117124
if (out.compressed && out.text.length < current.length) {
118125
current = out.text;
119126
usedFilterIds.add(filter.id);
120127
}
128+
i++;
121129
} catch (err) {
122-
// "Never make it worse." Drop the filter on error and keep going.
123-
this._logService.warn(`[ToolResultCompressor] filter ${filter.id} threw on tool ${toolName}: ${err}`);
130+
// "Never make it worse." Disable the filter for the rest of this
131+
// compression pass so it can't repeatedly throw on later text parts,
132+
// and warn at most once per filter.
133+
activeFilters.splice(i, 1);
134+
if (!disabledFilterIds.has(filter.id)) {
135+
disabledFilterIds.add(filter.id);
136+
this._logService.warn(`[ToolResultCompressor] filter ${filter.id} threw on tool ${toolName}; disabled for this pass: ${err}`);
137+
}
124138
}
125139
}
126140

127141
totalBefore += original.length;
128142
totalAfter += current.length;
129143
if (current !== original) {
130144
anyCompressed = true;
145+
// Preserve LanguageModelTextPart2 audience metadata if present.
146+
if (part instanceof LanguageModelTextPart2) {
147+
return new LanguageModelTextPart2(current, part.audience);
148+
}
131149
return new LanguageModelTextPart(current);
132150
}
133151
return part;
@@ -145,21 +163,21 @@ export class ToolResultCompressorService implements IToolResultCompressor {
145163
return compressed as vscode.LanguageModelToolResult;
146164
}
147165

148-
private _sendTelemetry(toolName: string, filterIds: string[], beforeBytes: number, afterBytes: number) {
166+
private _sendTelemetry(toolName: string, filterIds: string[], beforeChars: number, afterChars: number) {
149167
/* __GDPR__
150168
"toolResultCompressed" : {
151169
"owner": "meganrogge",
152170
"comment": "Reports tool output compression savings.",
153171
"toolName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The tool whose output was compressed." },
154172
"filters": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Comma-separated filter ids that fired." },
155-
"beforeBytes": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Total text part bytes before compression." },
156-
"afterBytes": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Total text part bytes after compression." }
173+
"beforeChars": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Total text part length in UTF-16 code units before compression." },
174+
"afterChars": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Total text part length in UTF-16 code units after compression." }
157175
}
158176
*/
159177
this._telemetryService.sendMSFTTelemetryEvent(
160178
'toolResultCompressed',
161179
{ toolName, filters: filterIds.join(',') },
162-
{ beforeBytes, afterBytes },
180+
{ beforeChars, afterChars },
163181
);
164182
}
165183
}

src/extension/tools/node/compressors/terminalOutputCompressor.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -56,21 +56,23 @@ export const gitDiffFilter: IToolResultFilter = {
5656
apply(text): IToolResultFilterOutput {
5757
const lines = text.split('\n');
5858
const out: string[] = [];
59-
let suppressed = 0;
59+
// Number of context lines to keep at the start of each unchanged run before
60+
// collapsing the rest into a single "... N omitted ..." marker.
61+
const KEEP_CONTEXT = 1;
62+
let contextRun = 0;
6063
let inBinaryOrLock = false;
61-
let currentFileHeader: string | null = null;
6264

63-
const flushSuppression = () => {
64-
if (suppressed > 0) {
65-
out.push(`... ${suppressed} unchanged context line${suppressed === 1 ? '' : 's'} omitted ...`);
66-
suppressed = 0;
65+
const flushContextRun = () => {
66+
const omitted = contextRun - KEEP_CONTEXT;
67+
if (omitted > 0) {
68+
out.push(`... ${omitted} unchanged context line${omitted === 1 ? '' : 's'} omitted ...`);
6769
}
70+
contextRun = 0;
6871
};
6972

7073
for (const line of lines) {
7174
if (line.startsWith('diff --git')) {
72-
flushSuppression();
73-
currentFileHeader = line;
75+
flushContextRun();
7476
inBinaryOrLock = /package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.lockb?$|\.snap$/.test(line);
7577
if (inBinaryOrLock) {
7678
out.push(line);
@@ -92,20 +94,19 @@ export const gitDiffFilter: IToolResultFilter = {
9294
// Keep file-mode markers, hunk markers, +/- lines verbatim.
9395
if (line.startsWith('+++ ') || line.startsWith('--- ') || line.startsWith('@@') ||
9496
line.startsWith('+') || line.startsWith('-') || line.startsWith('Binary files ')) {
95-
flushSuppression();
97+
flushContextRun();
9698
out.push(line);
9799
continue;
98100
}
99-
// Unchanged context line — suppress past 1 line per side.
100-
// Cheap heuristic: just count consecutive context runs and collapse runs >2.
101-
suppressed++;
102-
if (suppressed <= 1) {
101+
// Unchanged context line: keep the first KEEP_CONTEXT lines of each run,
102+
// then count the rest so the next non-context line can flush a single
103+
// summary marker.
104+
contextRun++;
105+
if (contextRun <= KEEP_CONTEXT) {
103106
out.push(line);
104-
suppressed = 0; // reset since we kept it
105107
}
106108
}
107-
flushSuppression();
108-
void currentFileHeader;
109+
flushContextRun();
109110

110111
const result = out.join('\n');
111112
return {

src/extension/tools/node/test/terminalOutputCompressor.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ describe('gitDiffFilter', () => {
5151
expect(out.text).toContain('@@ -1,3 +1,3 @@');
5252
expect(out.text).not.toContain('index abc..def');
5353
});
54+
it('collapses long unchanged-context runs into a single marker', () => {
55+
const ctxLines = Array.from({ length: 20 }, (_, i) => ` this is context line number ${i}`);
56+
const text = [
57+
'diff --git a/foo.ts b/foo.ts',
58+
'--- a/foo.ts',
59+
'+++ b/foo.ts',
60+
'@@ -1,22 +1,22 @@',
61+
...ctxLines,
62+
'-old',
63+
'+new',
64+
].join('\n');
65+
const out = gitDiffFilter.apply(text, input);
66+
// First context line is kept verbatim, the next 19 are collapsed.
67+
expect(out.text).toContain(' this is context line number 0');
68+
expect(out.text).not.toContain(' this is context line number 5');
69+
expect(out.text).not.toContain(' this is context line number 19');
70+
expect(out.text).toContain('19 unchanged context lines omitted');
71+
expect(out.text).toContain('-old');
72+
expect(out.text).toContain('+new');
73+
expect(out.compressed).toBe(true);
74+
});
5475
it('omits lockfile diffs', () => {
5576
const text = [
5677
'diff --git a/package-lock.json b/package-lock.json',

0 commit comments

Comments
 (0)