Skip to content

Commit dbddb2e

Browse files
authored
feat: group identical consecutive console messages in list_console_messages (#1939)
## Summary Group consecutive identical console messages in `list_console_messages`, similar to Chrome DevTools' console grouping behavior. Fixes #904 ## Changes - Introduce `GroupedConsoleFormatter` subclass that extends `ConsoleFormatter` and overrides `toString()` / `toJSON()` for count-aware formatting - Add `ConsoleFormatter.groupConsecutive()` static method that groups consecutive messages with the same type, text, and argument count - Apply grouping **before pagination** so grouped counts are accurate and page sizes reflect the collapsed view - Add unit tests for grouping logic, string formatting, and JSON output ## Key design decisions - **`GroupedConsoleFormatter` subclass**: Keeps the existing formatter interface clean — no new methods added to `ConsoleFormatter`. `ConsoleFormatter` and `GroupedConsoleFormatter` are interchangeable via the same interface. - **Grouping before pagination** (not at format time): This was the feedback on #963 and #1025 — grouping at format time breaks pagination counts. This implementation groups in `McpResponse` before calling `paginate()`. - **No `lastId`**: Since grouped messages are truly identical, only the first message's ID is needed. - **`argCount` matching**: Prevents false grouping of messages with the same text but different argument counts. ## Output example ``` msgid=1 [log] hello world (1 args) [5 times] ``` ## Testing - Unit tests in `tests/formatters/ConsoleFormatterGrouping.test.ts` - Manual verification: identical messages (×5), mixed pattern (A,A,B,A,A → A×2, B×1, A×2)
1 parent 9f5dd62 commit dbddb2e

3 files changed

Lines changed: 221 additions & 8 deletions

File tree

src/McpResponse.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,17 +1059,16 @@ Call ${handleDialog.name} to handle it before continuing.`);
10591059

10601060
response.push('## Console messages');
10611061
if (messages.length) {
1062+
const grouped = ConsoleFormatter.groupConsecutive(messages);
10621063
const paginationData = this.#dataWithPagination(
1063-
messages,
1064+
grouped,
10641065
this.#consoleDataOptions.pagination,
10651066
);
10661067
structuredContent.pagination = paginationData.pagination;
10671068
response.push(...paginationData.info);
1068-
response.push(
1069-
...paginationData.items.map(message => message.toString()),
1070-
);
1071-
structuredContent.consoleMessages = paginationData.items.map(message =>
1072-
message.toJSON(),
1069+
response.push(...paginationData.items.map(item => item.toString()));
1070+
structuredContent.consoleMessages = paginationData.items.map(item =>
1071+
item.toJSON(),
10731072
);
10741073
} else {
10751074
response.push('<no console messages found>');

src/formatters/ConsoleFormatter.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {UncaughtError} from '../PageCollector.js';
1313
import * as DevTools from '../third_party/index.js';
1414
import type {ConsoleMessage} from '../third_party/index.js';
1515

16+
import type {IssueFormatter} from './IssueFormatter.js';
17+
1618
export interface ConsoleFormatterOptions {
1719
fetchDetailedData?: boolean;
1820
id: number;
@@ -32,6 +34,7 @@ interface ConsoleMessageConcise {
3234
text: string;
3335
argsCount: number;
3436
id: number;
37+
count?: number;
3538
}
3639

3740
interface ConsoleMessageDetailed extends ConsoleMessageConcise {
@@ -54,7 +57,7 @@ export class ConsoleFormatter {
5457

5558
readonly isIgnored: IgnoreCheck;
5659

57-
private constructor(params: {
60+
protected constructor(params: {
5861
id: number;
5962
type: string;
6063
text: string;
@@ -201,6 +204,48 @@ export class ConsoleFormatter {
201204
};
202205
}
203206

207+
/**
208+
* Groups consecutive messages with the same type, text, and argument count.
209+
* Similar to Chrome DevTools' console grouping behavior.
210+
*/
211+
static groupConsecutive(
212+
messages: Array<ConsoleFormatter | IssueFormatter>,
213+
): Array<ConsoleFormatter | IssueFormatter> {
214+
const grouped: Array<{
215+
message: ConsoleFormatter | IssueFormatter;
216+
count: number;
217+
}> = [];
218+
for (const msg of messages) {
219+
const prev = grouped[grouped.length - 1];
220+
if (
221+
prev &&
222+
prev.message instanceof ConsoleFormatter &&
223+
msg instanceof ConsoleFormatter &&
224+
prev.message.#type === msg.#type &&
225+
prev.message.#text === msg.#text &&
226+
prev.message.#argCount === msg.#argCount
227+
) {
228+
prev.count++;
229+
} else {
230+
grouped.push({message: msg, count: 1});
231+
}
232+
}
233+
return grouped.map(({message, count}) =>
234+
count > 1 && message instanceof ConsoleFormatter
235+
? new GroupedConsoleFormatter(
236+
{
237+
id: message.#id,
238+
type: message.#type,
239+
text: message.#text,
240+
argCount: message.#argCount,
241+
isIgnored: message.isIgnored,
242+
},
243+
count,
244+
)
245+
: message,
246+
);
247+
}
248+
204249
toJSONDetailed(): ConsoleMessageDetailed {
205250
return {
206251
id: this.#id,
@@ -215,8 +260,37 @@ export class ConsoleFormatter {
215260
}
216261
}
217262

263+
export class GroupedConsoleFormatter extends ConsoleFormatter {
264+
readonly #count: number;
265+
266+
constructor(
267+
params: {
268+
id: number;
269+
type: string;
270+
text: string;
271+
argCount: number;
272+
isIgnored: IgnoreCheck;
273+
},
274+
count: number,
275+
) {
276+
super(params);
277+
this.#count = count;
278+
}
279+
280+
override toString(): string {
281+
return convertConsoleMessageConciseToString(this.toJSON());
282+
}
283+
284+
override toJSON(): ConsoleMessageConcise {
285+
const json = super.toJSON();
286+
json.count = this.#count;
287+
return json;
288+
}
289+
}
290+
218291
function convertConsoleMessageConciseToString(msg: ConsoleMessageConcise) {
219-
return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)`;
292+
const countSuffix = msg.count && msg.count > 1 ? ` [${msg.count} times]` : '';
293+
return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)${countSuffix}`;
220294
}
221295

222296
function convertConsoleMessageConciseDetailedToString(
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import {describe, it} from 'node:test';
9+
10+
import {
11+
ConsoleFormatter,
12+
GroupedConsoleFormatter,
13+
} from '../../src/formatters/ConsoleFormatter.js';
14+
import type {ConsoleMessage} from '../../src/third_party/index.js';
15+
16+
const createMockMessage = (
17+
type: string,
18+
text: string,
19+
argsCount = 0,
20+
): ConsoleMessage => {
21+
const args = Array.from({length: argsCount}, () => ({
22+
jsonValue: async () => 'val',
23+
remoteObject: () => ({type: 'string'}),
24+
}));
25+
return {
26+
type: () => type,
27+
text: () => text,
28+
args: () => args,
29+
} as unknown as ConsoleMessage;
30+
};
31+
32+
const makeFormatter = (id: number, type: string, text: string, argsCount = 0) =>
33+
ConsoleFormatter.from(createMockMessage(type, text, argsCount), {id});
34+
35+
describe('ConsoleFormatter grouping', () => {
36+
describe('groupConsecutive', () => {
37+
it('groups identical consecutive messages', async () => {
38+
const msgs = await Promise.all([
39+
makeFormatter(1, 'log', 'hello'),
40+
makeFormatter(2, 'log', 'hello'),
41+
makeFormatter(3, 'log', 'hello'),
42+
]);
43+
const grouped = ConsoleFormatter.groupConsecutive(msgs);
44+
assert.strictEqual(grouped.length, 1);
45+
assert.ok(grouped[0] instanceof GroupedConsoleFormatter);
46+
assert.ok(grouped[0].toString().includes('[3 times]'));
47+
});
48+
49+
it('does not group different messages', async () => {
50+
const msgs = await Promise.all([
51+
makeFormatter(1, 'log', 'aaa'),
52+
makeFormatter(2, 'log', 'bbb'),
53+
makeFormatter(3, 'log', 'ccc'),
54+
]);
55+
const grouped = ConsoleFormatter.groupConsecutive(msgs);
56+
assert.strictEqual(grouped.length, 3);
57+
for (const g of grouped) {
58+
assert.ok(!(g instanceof GroupedConsoleFormatter));
59+
assert.ok(!g.toString().includes('times'));
60+
}
61+
});
62+
63+
it('groups A,A,B,A,A correctly', async () => {
64+
const msgs = await Promise.all([
65+
makeFormatter(1, 'log', 'A'),
66+
makeFormatter(2, 'log', 'A'),
67+
makeFormatter(3, 'log', 'B'),
68+
makeFormatter(4, 'log', 'A'),
69+
makeFormatter(5, 'log', 'A'),
70+
]);
71+
const grouped = ConsoleFormatter.groupConsecutive(msgs);
72+
assert.strictEqual(grouped.length, 3);
73+
assert.ok(grouped[0] instanceof GroupedConsoleFormatter);
74+
assert.ok(grouped[0].toString().includes('[2 times]'));
75+
assert.ok(!(grouped[1] instanceof GroupedConsoleFormatter));
76+
assert.ok(grouped[2] instanceof GroupedConsoleFormatter);
77+
assert.ok(grouped[2].toString().includes('[2 times]'));
78+
});
79+
80+
it('does not group messages with different types', async () => {
81+
const msgs = await Promise.all([
82+
makeFormatter(1, 'log', 'hello'),
83+
makeFormatter(2, 'error', 'hello'),
84+
]);
85+
const grouped = ConsoleFormatter.groupConsecutive(msgs);
86+
assert.strictEqual(grouped.length, 2);
87+
});
88+
89+
it('does not group messages with different argsCount', async () => {
90+
const msgs = await Promise.all([
91+
makeFormatter(1, 'log', 'hello', 1),
92+
makeFormatter(2, 'log', 'hello', 2),
93+
]);
94+
const grouped = ConsoleFormatter.groupConsecutive(msgs);
95+
assert.strictEqual(grouped.length, 2);
96+
});
97+
98+
it('returns empty array for empty input', () => {
99+
const grouped = ConsoleFormatter.groupConsecutive([]);
100+
assert.strictEqual(grouped.length, 0);
101+
});
102+
103+
it('handles single message', async () => {
104+
const msgs = await Promise.all([makeFormatter(1, 'log', 'solo')]);
105+
const grouped = ConsoleFormatter.groupConsecutive(msgs);
106+
assert.strictEqual(grouped.length, 1);
107+
assert.ok(!(grouped[0] instanceof GroupedConsoleFormatter));
108+
});
109+
});
110+
111+
describe('GroupedConsoleFormatter output', () => {
112+
it('toString includes count suffix', async () => {
113+
const msgs = await Promise.all([
114+
makeFormatter(1, 'log', 'hello'),
115+
makeFormatter(2, 'log', 'hello'),
116+
makeFormatter(3, 'log', 'hello'),
117+
makeFormatter(4, 'log', 'hello'),
118+
makeFormatter(5, 'log', 'hello'),
119+
]);
120+
const grouped = ConsoleFormatter.groupConsecutive(msgs);
121+
assert.strictEqual(grouped.length, 1);
122+
const str = grouped[0].toString();
123+
assert.ok(str.includes('[5 times]'), `expected [5 times] in: ${str}`);
124+
assert.ok(str.includes('msgid=1'), `expected msgid=1 in: ${str}`);
125+
});
126+
127+
it('toJSON includes count field', async () => {
128+
const msgs = await Promise.all([
129+
makeFormatter(1, 'log', 'hello'),
130+
makeFormatter(2, 'log', 'hello'),
131+
makeFormatter(3, 'log', 'hello'),
132+
]);
133+
const grouped = ConsoleFormatter.groupConsecutive(msgs);
134+
assert.strictEqual(grouped.length, 1);
135+
const json = (grouped[0] as GroupedConsoleFormatter).toJSON();
136+
assert.strictEqual(json.count, 3);
137+
assert.strictEqual(json.id, 1);
138+
});
139+
});
140+
});

0 commit comments

Comments
 (0)