Skip to content

Commit 46d187b

Browse files
committed
feat: group identical consecutive console messages in list_console_messages
Group consecutive messages with the same type, text, and argument count, displaying a count suffix (e.g. [5 times, last msgid=5]) similar to Chrome DevTools' console grouping behavior. Grouping is applied before pagination so the grouped count accurately reflects the total items. - Add groupConsecutive() static method to ConsoleFormatter - Add toStringGrouped()/toJSONGrouped() for count-aware formatting - Add lastId to grouped output for accessing last message in a group - Add argCount equality check to prevent false grouping - Apply grouping in McpResponse before pagination - Add unit tests for grouping logic Fixes #904
1 parent b53752d commit 46d187b

3 files changed

Lines changed: 213 additions & 5 deletions

File tree

src/McpResponse.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,17 +1031,25 @@ Call ${handleDialog.name} to handle it before continuing.`);
10311031

10321032
response.push('## Console messages');
10331033
if (messages.length) {
1034+
const grouped = ConsoleFormatter.groupConsecutive(messages);
10341035
const paginationData = this.#dataWithPagination(
1035-
messages,
1036+
grouped,
10361037
this.#consoleDataOptions.pagination,
10371038
);
10381039
structuredContent.pagination = paginationData.pagination;
10391040
response.push(...paginationData.info);
10401041
response.push(
1041-
...paginationData.items.map(message => message.toString()),
1042+
...paginationData.items.map(({message, count, lastId}) =>
1043+
message instanceof ConsoleFormatter
1044+
? message.toStringGrouped(count, lastId)
1045+
: message.toString(),
1046+
),
10421047
);
1043-
structuredContent.consoleMessages = paginationData.items.map(message =>
1044-
message.toJSON(),
1048+
structuredContent.consoleMessages = paginationData.items.map(
1049+
({message, count, lastId}) =>
1050+
message instanceof ConsoleFormatter
1051+
? message.toJSONGrouped(count, lastId)
1052+
: message.toJSON(),
10451053
);
10461054
} else {
10471055
response.push('<no console messages found>');

src/formatters/ConsoleFormatter.ts

Lines changed: 62 additions & 1 deletion
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,8 @@ interface ConsoleMessageConcise {
3234
text: string;
3335
argsCount: number;
3436
id: number;
37+
count?: number;
38+
lastId?: number;
3539
}
3640

3741
interface ConsoleMessageDetailed extends ConsoleMessageConcise {
@@ -175,6 +179,18 @@ export class ConsoleFormatter {
175179
return convertConsoleMessageConciseToString(this.toJSON());
176180
}
177181

182+
// The short format with a repeat count.
183+
toStringGrouped(count: number, lastId?: number): string {
184+
const json = this.toJSON();
185+
if (count > 1) {
186+
json.count = count;
187+
if (lastId !== undefined) {
188+
json.lastId = lastId;
189+
}
190+
}
191+
return convertConsoleMessageConciseToString(json);
192+
}
193+
178194
// The verbose format for a console message, including all details.
179195
toStringDetailed(): string {
180196
return convertConsoleMessageConciseDetailedToString(this.toJSONDetailed());
@@ -201,6 +217,48 @@ export class ConsoleFormatter {
201217
};
202218
}
203219

220+
toJSONGrouped(count: number, lastId?: number): ConsoleMessageConcise {
221+
const json = this.toJSON();
222+
if (count > 1) {
223+
json.count = count;
224+
if (lastId !== undefined) {
225+
json.lastId = lastId;
226+
}
227+
}
228+
return json;
229+
}
230+
231+
/**
232+
* Groups consecutive messages with the same type, text, and argument count.
233+
* Similar to Chrome DevTools' console grouping behavior.
234+
*/
235+
static groupConsecutive(
236+
messages: Array<ConsoleFormatter | IssueFormatter>,
237+
): Array<{message: ConsoleFormatter | IssueFormatter; count: number; lastId?: number}> {
238+
const grouped: Array<{
239+
message: ConsoleFormatter | IssueFormatter;
240+
count: number;
241+
lastId?: number;
242+
}> = [];
243+
for (const msg of messages) {
244+
const prev = grouped[grouped.length - 1];
245+
if (
246+
prev &&
247+
prev.message instanceof ConsoleFormatter &&
248+
msg instanceof ConsoleFormatter &&
249+
prev.message.#type === msg.#type &&
250+
prev.message.#text === msg.#text &&
251+
prev.message.#argCount === msg.#argCount
252+
) {
253+
prev.count++;
254+
prev.lastId = msg.#id;
255+
} else {
256+
grouped.push({message: msg, count: 1});
257+
}
258+
}
259+
return grouped;
260+
}
261+
204262
toJSONDetailed(): ConsoleMessageDetailed {
205263
return {
206264
id: this.#id,
@@ -216,7 +274,10 @@ export class ConsoleFormatter {
216274
}
217275

218276
function convertConsoleMessageConciseToString(msg: ConsoleMessageConcise) {
219-
return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)`;
277+
const countSuffix = msg.count && msg.count > 1
278+
? ` [${msg.count} times${msg.lastId ? `, last msgid=${msg.lastId}` : ''}]`
279+
: '';
280+
return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)${countSuffix}`;
220281
}
221282

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

0 commit comments

Comments
 (0)