Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<no console messages found>');
Expand Down
63 changes: 62 additions & 1 deletion src/formatters/ConsoleFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +34,8 @@ interface ConsoleMessageConcise {
text: string;
argsCount: number;
id: number;
count?: number;
lastId?: number;
}

interface ConsoleMessageDetailed extends ConsoleMessageConcise {
Expand Down Expand Up @@ -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());
Expand All @@ -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<ConsoleFormatter | IssueFormatter>,
): 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,
Expand All @@ -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(
Expand Down
139 changes: 139 additions & 0 deletions tests/formatters/ConsoleFormatterGrouping.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});