diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 45e587354..f75314230 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, lastId}) => + message instanceof ConsoleFormatter + ? message.toStringGrouped(count, lastId) + : message.toString(), + ), ); - structuredContent.consoleMessages = paginationData.items.map(message => - message.toJSON(), + structuredContent.consoleMessages = paginationData.items.map( + ({message, count, lastId}) => + message instanceof ConsoleFormatter + ? message.toJSONGrouped(count, lastId) + : message.toJSON(), ); } else { response.push(''); diff --git a/src/formatters/ConsoleFormatter.ts b/src/formatters/ConsoleFormatter.ts index 4cefab55e..1e0628086 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,8 @@ interface ConsoleMessageConcise { text: string; argsCount: number; id: number; + count?: number; + lastId?: number; } interface ConsoleMessageDetailed extends ConsoleMessageConcise { @@ -175,6 +179,18 @@ export class ConsoleFormatter { return convertConsoleMessageConciseToString(this.toJSON()); } + // The short format with a repeat count. + 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); + } + // The verbose format for a console message, including all details. toStringDetailed(): string { return convertConsoleMessageConciseDetailedToString(this.toJSONDetailed()); @@ -201,6 +217,48 @@ export class ConsoleFormatter { }; } + 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, text, and argument count. + * Similar to Chrome DevTools' console grouping behavior. + */ + static groupConsecutive( + messages: Array, + ): 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]; + 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++; + prev.lastId = msg.#id; + } else { + grouped.push({message: msg, count: 1}); + } + } + return grouped; + } + toJSONDetailed(): ConsoleMessageDetailed { return { id: this.#id, @@ -216,7 +274,10 @@ 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${msg.lastId ? `, last msgid=${msg.lastId}` : ''}]` + : ''; + return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)${countSuffix}`; } function convertConsoleMessageConciseDetailedToString( 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); + }); + }); +});