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)
This commit is contained in:
@masamaru0513
2026-04-23 18:55:18 +09:00
committed by GitHub
parent 9f5dd6229c
commit dbddb2e4ef
3 changed files with 221 additions and 8 deletions
+5 -6
View File
@@ -1059,17 +1059,16 @@ Call ${handleDialog.name} to handle it before continuing.`);
response.push('## Console messages');
if (messages.length) {
const grouped = ConsoleFormatter.groupConsecutive(messages);
const paginationData = this.#dataWithPagination(
messages,
grouped,
this.#consoleDataOptions.pagination,
);
structuredContent.pagination = paginationData.pagination;
response.push(...paginationData.info);
response.push(
...paginationData.items.map(message => message.toString()),
);
structuredContent.consoleMessages = paginationData.items.map(message =>
message.toJSON(),
response.push(...paginationData.items.map(item => item.toString()));
structuredContent.consoleMessages = paginationData.items.map(item =>
item.toJSON(),
);
} else {
response.push('<no console messages found>');
+76 -2
View File
@@ -13,6 +13,8 @@ import {UncaughtError} from '../PageCollector.js';
import * as DevTools from '../third_party/index.js';
import type {ConsoleMessage} from '../third_party/index.js';
import type {IssueFormatter} from './IssueFormatter.js';
export interface ConsoleFormatterOptions {
fetchDetailedData?: boolean;
id: number;
@@ -32,6 +34,7 @@ interface ConsoleMessageConcise {
text: string;
argsCount: number;
id: number;
count?: number;
}
interface ConsoleMessageDetailed extends ConsoleMessageConcise {
@@ -54,7 +57,7 @@ export class ConsoleFormatter {
readonly isIgnored: IgnoreCheck;
private constructor(params: {
protected constructor(params: {
id: number;
type: string;
text: string;
@@ -201,6 +204,48 @@ export class ConsoleFormatter {
};
}
/**
* Groups consecutive messages with the same type, text, and argument count.
* Similar to Chrome DevTools' console grouping behavior.
*/
static groupConsecutive(
messages: Array<ConsoleFormatter | IssueFormatter>,
): Array<ConsoleFormatter | IssueFormatter> {
const grouped: Array<{
message: ConsoleFormatter | IssueFormatter;
count: number;
}> = [];
for (const msg of messages) {
const prev = grouped[grouped.length - 1];
if (
prev &&
prev.message instanceof ConsoleFormatter &&
msg instanceof ConsoleFormatter &&
prev.message.#type === msg.#type &&
prev.message.#text === msg.#text &&
prev.message.#argCount === msg.#argCount
) {
prev.count++;
} else {
grouped.push({message: msg, count: 1});
}
}
return grouped.map(({message, count}) =>
count > 1 && message instanceof ConsoleFormatter
? new GroupedConsoleFormatter(
{
id: message.#id,
type: message.#type,
text: message.#text,
argCount: message.#argCount,
isIgnored: message.isIgnored,
},
count,
)
: message,
);
}
toJSONDetailed(): ConsoleMessageDetailed {
return {
id: this.#id,
@@ -215,8 +260,37 @@ export class ConsoleFormatter {
}
}
export class GroupedConsoleFormatter extends ConsoleFormatter {
readonly #count: number;
constructor(
params: {
id: number;
type: string;
text: string;
argCount: number;
isIgnored: IgnoreCheck;
},
count: number,
) {
super(params);
this.#count = count;
}
override toString(): string {
return convertConsoleMessageConciseToString(this.toJSON());
}
override toJSON(): ConsoleMessageConcise {
const json = super.toJSON();
json.count = this.#count;
return json;
}
}
function convertConsoleMessageConciseToString(msg: ConsoleMessageConcise) {
return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)`;
const countSuffix = msg.count && msg.count > 1 ? ` [${msg.count} times]` : '';
return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)${countSuffix}`;
}
function convertConsoleMessageConciseDetailedToString(
@@ -0,0 +1,140 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {
ConsoleFormatter,
GroupedConsoleFormatter,
} from '../../src/formatters/ConsoleFormatter.js';
import type {ConsoleMessage} from '../../src/third_party/index.js';
const createMockMessage = (
type: string,
text: string,
argsCount = 0,
): ConsoleMessage => {
const args = Array.from({length: argsCount}, () => ({
jsonValue: async () => 'val',
remoteObject: () => ({type: 'string'}),
}));
return {
type: () => type,
text: () => text,
args: () => args,
} as unknown as ConsoleMessage;
};
const makeFormatter = (id: number, type: string, text: string, argsCount = 0) =>
ConsoleFormatter.from(createMockMessage(type, text, argsCount), {id});
describe('ConsoleFormatter grouping', () => {
describe('groupConsecutive', () => {
it('groups identical consecutive messages', async () => {
const msgs = await Promise.all([
makeFormatter(1, 'log', 'hello'),
makeFormatter(2, 'log', 'hello'),
makeFormatter(3, 'log', 'hello'),
]);
const grouped = ConsoleFormatter.groupConsecutive(msgs);
assert.strictEqual(grouped.length, 1);
assert.ok(grouped[0] instanceof GroupedConsoleFormatter);
assert.ok(grouped[0].toString().includes('[3 times]'));
});
it('does not group different messages', async () => {
const msgs = await Promise.all([
makeFormatter(1, 'log', 'aaa'),
makeFormatter(2, 'log', 'bbb'),
makeFormatter(3, 'log', 'ccc'),
]);
const grouped = ConsoleFormatter.groupConsecutive(msgs);
assert.strictEqual(grouped.length, 3);
for (const g of grouped) {
assert.ok(!(g instanceof GroupedConsoleFormatter));
assert.ok(!g.toString().includes('times'));
}
});
it('groups A,A,B,A,A correctly', async () => {
const msgs = await Promise.all([
makeFormatter(1, 'log', 'A'),
makeFormatter(2, 'log', 'A'),
makeFormatter(3, 'log', 'B'),
makeFormatter(4, 'log', 'A'),
makeFormatter(5, 'log', 'A'),
]);
const grouped = ConsoleFormatter.groupConsecutive(msgs);
assert.strictEqual(grouped.length, 3);
assert.ok(grouped[0] instanceof GroupedConsoleFormatter);
assert.ok(grouped[0].toString().includes('[2 times]'));
assert.ok(!(grouped[1] instanceof GroupedConsoleFormatter));
assert.ok(grouped[2] instanceof GroupedConsoleFormatter);
assert.ok(grouped[2].toString().includes('[2 times]'));
});
it('does not group messages with different types', async () => {
const msgs = await Promise.all([
makeFormatter(1, 'log', 'hello'),
makeFormatter(2, 'error', 'hello'),
]);
const grouped = ConsoleFormatter.groupConsecutive(msgs);
assert.strictEqual(grouped.length, 2);
});
it('does not group messages with different argsCount', async () => {
const msgs = await Promise.all([
makeFormatter(1, 'log', 'hello', 1),
makeFormatter(2, 'log', 'hello', 2),
]);
const grouped = ConsoleFormatter.groupConsecutive(msgs);
assert.strictEqual(grouped.length, 2);
});
it('returns empty array for empty input', () => {
const grouped = ConsoleFormatter.groupConsecutive([]);
assert.strictEqual(grouped.length, 0);
});
it('handles single message', async () => {
const msgs = await Promise.all([makeFormatter(1, 'log', 'solo')]);
const grouped = ConsoleFormatter.groupConsecutive(msgs);
assert.strictEqual(grouped.length, 1);
assert.ok(!(grouped[0] instanceof GroupedConsoleFormatter));
});
});
describe('GroupedConsoleFormatter output', () => {
it('toString includes count suffix', async () => {
const msgs = await Promise.all([
makeFormatter(1, 'log', 'hello'),
makeFormatter(2, 'log', 'hello'),
makeFormatter(3, 'log', 'hello'),
makeFormatter(4, 'log', 'hello'),
makeFormatter(5, 'log', 'hello'),
]);
const grouped = ConsoleFormatter.groupConsecutive(msgs);
assert.strictEqual(grouped.length, 1);
const str = grouped[0].toString();
assert.ok(str.includes('[5 times]'), `expected [5 times] in: ${str}`);
assert.ok(str.includes('msgid=1'), `expected msgid=1 in: ${str}`);
});
it('toJSON includes count field', async () => {
const msgs = await Promise.all([
makeFormatter(1, 'log', 'hello'),
makeFormatter(2, 'log', 'hello'),
makeFormatter(3, 'log', 'hello'),
]);
const grouped = ConsoleFormatter.groupConsecutive(msgs);
assert.strictEqual(grouped.length, 1);
const json = (grouped[0] as GroupedConsoleFormatter).toJSON();
assert.strictEqual(json.count, 3);
assert.strictEqual(json.id, 1);
});
});
});