From e0fa7ae69d13d39534431a42b4f67bda002ece35 Mon Sep 17 00:00:00 2001 From: masahirokokubo0513 <19896624+masamaru0513@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:56:55 +0900 Subject: [PATCH 1/2] feat: group identical consecutive console messages in list_console_messages Group consecutive messages with the same type and text, displaying a count suffix (e.g. [20 times]) 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 optional count field to ConsoleMessageConcise interface - Apply grouping in McpResponse before pagination Fixes #904 --- src/McpResponse.ts | 16 +++++++--- src/formatters/ConsoleFormatter.ts | 51 +++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 45e587354..91895ac94 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -1031,17 +1031,25 @@ 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()), + ...paginationData.items.map(({message, count}) => + message instanceof ConsoleFormatter + ? message.toStringGrouped(count) + : message.toString(), + ), ); - structuredContent.consoleMessages = paginationData.items.map(message => - message.toJSON(), + structuredContent.consoleMessages = paginationData.items.map( + ({message, count}) => + message instanceof ConsoleFormatter + ? message.toJSONGrouped(count) + : message.toJSON(), ); } else { response.push(''); diff --git a/src/formatters/ConsoleFormatter.ts b/src/formatters/ConsoleFormatter.ts index 4cefab55e..8187f4e0d 100644 --- a/src/formatters/ConsoleFormatter.ts +++ b/src/formatters/ConsoleFormatter.ts @@ -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 { @@ -175,6 +178,15 @@ export class ConsoleFormatter { return convertConsoleMessageConciseToString(this.toJSON()); } + // The short format with a repeat count. + toStringGrouped(count: number): string { + const json = this.toJSON(); + if (count > 1) { + json.count = count; + } + return convertConsoleMessageConciseToString(json); + } + // The verbose format for a console message, including all details. toStringDetailed(): string { return convertConsoleMessageConciseDetailedToString(this.toJSONDetailed()); @@ -201,6 +213,42 @@ export class ConsoleFormatter { }; } + toJSONGrouped(count: number): ConsoleMessageConcise { + const json = this.toJSON(); + if (count > 1) { + json.count = count; + } + return json; + } + + /** + * Groups consecutive messages with the same type and text. + * Similar to Chrome DevTools' console grouping behavior. + */ + static groupConsecutive( + messages: Array, + ): Array<{message: ConsoleFormatter | IssueFormatter; count: number}> { + 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.count++; + } else { + grouped.push({message: msg, count: 1}); + } + } + return grouped; + } + toJSONDetailed(): ConsoleMessageDetailed { return { id: this.#id, @@ -216,7 +264,8 @@ export class ConsoleFormatter { } 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( From aad515d82a7135a525aee5b4b137a4734dcdf2f1 Mon Sep 17 00:00:00 2001 From: masahirokokubo0513 <19896624+masamaru0513@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:23:00 +0900 Subject: [PATCH 2/2] feat: add lastId to grouped console messages and match by argCount Extend console message grouping to include lastId so that consumers can access the last message in a group via get_console_message. Also require argCount to match for grouping, preventing false grouping of messages with different argument counts. - Add lastId field to grouped output (string and JSON) - Add argCount equality check in groupConsecutive() - Add unit tests for grouping, toStringGrouped, toJSONGrouped Fixes #904 --- src/McpResponse.ts | 8 +- src/formatters/ConsoleFormatter.ts | 24 ++- .../ConsoleFormatterGrouping.test.ts | 139 ++++++++++++++++++ 3 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 tests/formatters/ConsoleFormatterGrouping.test.ts diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 91895ac94..f75314230 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -1039,16 +1039,16 @@ Call ${handleDialog.name} to handle it before continuing.`); structuredContent.pagination = paginationData.pagination; response.push(...paginationData.info); response.push( - ...paginationData.items.map(({message, count}) => + ...paginationData.items.map(({message, count, lastId}) => message instanceof ConsoleFormatter - ? message.toStringGrouped(count) + ? message.toStringGrouped(count, lastId) : message.toString(), ), ); structuredContent.consoleMessages = paginationData.items.map( - ({message, count}) => + ({message, count, lastId}) => message instanceof ConsoleFormatter - ? message.toJSONGrouped(count) + ? message.toJSONGrouped(count, lastId) : message.toJSON(), ); } else { diff --git a/src/formatters/ConsoleFormatter.ts b/src/formatters/ConsoleFormatter.ts index 8187f4e0d..1e0628086 100644 --- a/src/formatters/ConsoleFormatter.ts +++ b/src/formatters/ConsoleFormatter.ts @@ -35,6 +35,7 @@ interface ConsoleMessageConcise { argsCount: number; id: number; count?: number; + lastId?: number; } interface ConsoleMessageDetailed extends ConsoleMessageConcise { @@ -179,10 +180,13 @@ export class ConsoleFormatter { } // The short format with a repeat count. - toStringGrouped(count: number): string { + toStringGrouped(count: number, lastId?: number): string { const json = this.toJSON(); if (count > 1) { json.count = count; + if (lastId !== undefined) { + json.lastId = lastId; + } } return convertConsoleMessageConciseToString(json); } @@ -213,24 +217,28 @@ export class ConsoleFormatter { }; } - toJSONGrouped(count: number): ConsoleMessageConcise { + toJSONGrouped(count: number, lastId?: number): ConsoleMessageConcise { const json = this.toJSON(); if (count > 1) { json.count = count; + if (lastId !== undefined) { + json.lastId = lastId; + } } return json; } /** - * Groups consecutive messages with the same type and text. + * Groups consecutive messages with the same type, text, and argument count. * Similar to Chrome DevTools' console grouping behavior. */ static groupConsecutive( messages: Array, - ): Array<{message: ConsoleFormatter | IssueFormatter; count: number}> { + ): Array<{message: ConsoleFormatter | IssueFormatter; count: number; lastId?: number}> { const grouped: Array<{ message: ConsoleFormatter | IssueFormatter; count: number; + lastId?: number; }> = []; for (const msg of messages) { const prev = grouped[grouped.length - 1]; @@ -239,9 +247,11 @@ export class ConsoleFormatter { prev.message instanceof ConsoleFormatter && msg instanceof ConsoleFormatter && prev.message.#type === msg.#type && - prev.message.#text === msg.#text + prev.message.#text === msg.#text && + prev.message.#argCount === msg.#argCount ) { prev.count++; + prev.lastId = msg.#id; } else { grouped.push({message: msg, count: 1}); } @@ -264,7 +274,9 @@ export class ConsoleFormatter { } function convertConsoleMessageConciseToString(msg: ConsoleMessageConcise) { - const countSuffix = msg.count && msg.count > 1 ? ` [${msg.count} times]` : ''; + const countSuffix = msg.count && msg.count > 1 + ? ` [${msg.count} times${msg.lastId ? `, last msgid=${msg.lastId}` : ''}]` + : ''; return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)${countSuffix}`; } diff --git a/tests/formatters/ConsoleFormatterGrouping.test.ts b/tests/formatters/ConsoleFormatterGrouping.test.ts new file mode 100644 index 000000000..6a43b0d9e --- /dev/null +++ b/tests/formatters/ConsoleFormatterGrouping.test.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import {ConsoleFormatter} 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.strictEqual(grouped[0].count, 3); + assert.strictEqual(grouped[0].lastId, 3); + }); + + 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.strictEqual(g.count, 1); + assert.strictEqual(g.lastId, undefined); + } + }); + + 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.strictEqual(grouped[0].count, 2); + assert.strictEqual(grouped[0].lastId, 2); + assert.strictEqual(grouped[1].count, 1); + assert.strictEqual(grouped[1].lastId, undefined); + assert.strictEqual(grouped[2].count, 2); + assert.strictEqual(grouped[2].lastId, 5); + }); + + 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.strictEqual(grouped[0].count, 1); + assert.strictEqual(grouped[0].lastId, undefined); + }); + }); + + describe('toStringGrouped', () => { + it('appends count and lastId suffix when count > 1', async () => { + const f = await makeFormatter(1, 'log', 'hello'); + const str = f.toStringGrouped(5, 5); + assert.ok(str.includes('[5 times, last msgid=5]'), `expected [5 times, last msgid=5] in: ${str}`); + }); + + it('does not append count suffix when count is 1', async () => { + const f = await makeFormatter(1, 'log', 'hello'); + const str = f.toStringGrouped(1); + assert.ok(!str.includes('times'), `unexpected times in: ${str}`); + }); + }); + + describe('toJSONGrouped', () => { + it('includes count and lastId when count > 1', async () => { + const f = await makeFormatter(1, 'log', 'hello'); + const json = f.toJSONGrouped(3, 3); + assert.strictEqual(json.count, 3); + assert.strictEqual(json.lastId, 3); + }); + + it('does not include count or lastId when count is 1', async () => { + const f = await makeFormatter(1, 'log', 'hello'); + const json = f.toJSONGrouped(1); + assert.strictEqual(json.count, undefined); + assert.strictEqual(json.lastId, undefined); + }); + }); +});