From d5f613ba589090f1488496abf62ee323d581edb3 Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:12:39 +0000 Subject: [PATCH] feat(inspect): add --filter flag to narrow timeline by method When --filter is passed, inspectTranscript shows only the request frames whose method key (or tools/call[name] for tool calls) contains the substring, together with their paired response frames. The summary footer always covers the full transcript so counts remain meaningful. An empty or absent filter is a no-op for backward compatibility. CLI: mcprec inspect --filter tools/call[search_issues] --- src/cli.ts | 8 ++- src/inspect.ts | 55 +++++++++++++++- test/inspect.filter.test.ts | 126 ++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 test/inspect.filter.test.ts diff --git a/src/cli.ts b/src/cli.ts index 6e1b806..ae4ffea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,8 +45,12 @@ program program .command("inspect ") .description("Pretty-print a transcript") - .action(async (file: string) => { - const out = await inspectTranscript(file); + .option( + "--filter ", + "only show frames whose method key contains this substring (e.g. 'tools/call', 'tools/call[search_issues]')", + ) + .action(async (file: string, opts: { filter?: string }) => { + const out = await inspectTranscript(file, { filter: opts.filter }); process.stdout.write(out + "\n"); }); diff --git a/src/inspect.ts b/src/inspect.ts index dfc32ff..da1344d 100644 --- a/src/inspect.ts +++ b/src/inspect.ts @@ -29,10 +29,58 @@ export function transcriptStats(frames: Frame[]): TranscriptStats { }; } -export async function inspectTranscript(file: string): Promise { +export interface InspectOptions { + /** Substring filter applied to the method key of each request frame. + * tools/call frames match against "tools/call[]". When set, + * only matching request frames and their paired response frames are + * shown in the timeline; the summary footer always covers the full + * transcript. */ + filter?: string; +} + +/** Return the display key for a request frame (method, or tools/call[name]). */ +function frameMethodKey(f: Frame): string | undefined { + if (f.dir !== "→") return undefined; + const msg = f.msg as { method?: string; params?: unknown }; + if (!msg.method) return undefined; + if (msg.method === "tools/call") { + const toolName = (msg.params as { name?: unknown } | undefined)?.name; + if (typeof toolName === "string" && toolName) { + return `tools/call[${toolName}]`; + } + } + return msg.method; +} + +export async function inspectTranscript( + file: string, + opts: InspectOptions = {}, +): Promise { const frames = await loadTranscript(file); const lines: string[] = []; - for (const f of frames) { + + const { filter } = opts; + let displayFrames: Frame[]; + if (filter) { + const matchedIds = new Set(); + for (const f of frames) { + const key = frameMethodKey(f); + if (key && key.includes(filter)) { + const msg = f.msg as { id?: unknown }; + if (msg.id !== undefined) matchedIds.add(msg.id as string | number); + } + } + displayFrames = frames.filter((f) => { + const key = frameMethodKey(f); + if (key !== undefined) return key.includes(filter); + const msg = f.msg as { id?: unknown }; + return msg.id !== undefined && matchedIds.has(msg.id as string | number); + }); + } else { + displayFrames = frames; + } + + for (const f of displayFrames) { lines.push(formatFrame(f)); } const pairs = pairFrames(frames); @@ -44,6 +92,9 @@ export async function inspectTranscript(file: string): Promise { `${frames.length} frames · ${pairs.length} request/response pairs · ${duration}s`, ), ); + if (filter) { + lines.push(pc.dim(`filter: ${filter} (${displayFrames.length} frames shown)`)); + } lines.push(pc.dim("methods:")); for (const [method, count] of methodCounts) { lines.push(pc.dim(` ${method}: ${count}`)); diff --git a/test/inspect.filter.test.ts b/test/inspect.filter.test.ts new file mode 100644 index 0000000..4a90a47 --- /dev/null +++ b/test/inspect.filter.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { inspectTranscript } from "../src/inspect.js"; +import type { Frame } from "../src/types.js"; + +function frameLine(f: Frame): string { + return JSON.stringify(f); +} + +describe("inspectTranscript --filter", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mcprec-filter-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function write(name: string, frames: Frame[]): string { + const file = join(dir, name); + writeFileSync(file, frames.map(frameLine).join("\n") + "\n", "utf8"); + return file; + } + + const mixedFrames: Frame[] = [ + { t: 0.0, dir: "→", msg: { jsonrpc: "2.0", id: 1, method: "tools/list" } }, + { t: 0.1, dir: "←", msg: { jsonrpc: "2.0", id: 1, result: { tools: [] } } }, + { t: 0.2, dir: "→", msg: { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "search_issues", arguments: { q: "is:open" } } } }, + { t: 0.9, dir: "←", msg: { jsonrpc: "2.0", id: 2, result: { content: [] } } }, + { t: 1.0, dir: "→", msg: { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "get_file", arguments: { path: "README.md" } } } }, + { t: 1.2, dir: "←", msg: { jsonrpc: "2.0", id: 3, result: { content: "..." } } }, + { t: 1.3, dir: "→", msg: { jsonrpc: "2.0", id: 4, method: "ping" } }, + { t: 1.4, dir: "←", msg: { jsonrpc: "2.0", id: 4, result: {} } }, + ]; + + it("shows only matching request frames and their responses when filter is set", async () => { + const file = write("mixed.jsonl", mixedFrames); + const out = await inspectTranscript(file, { filter: "tools/list" }); + + // Should contain tools/list. + expect(out).toContain("tools/list"); + // Should not show tools/call or ping frames in the timeline. + const frameLines = out.split("\n").filter((l) => /^\s*\d+\.\d{3}s/.test(l)); + expect(frameLines.every((l) => !l.includes("tools/call") && !l.includes("ping"))).toBe(true); + // Should have exactly 2 frame lines: the request and its response. + expect(frameLines).toHaveLength(2); + }); + + it("matches tools/call frames by tool name substring", async () => { + const file = write("mixed2.jsonl", mixedFrames); + const out = await inspectTranscript(file, { filter: "tools/call[search_issues]" }); + + const frameLines = out.split("\n").filter((l) => /^\s*\d+\.\d{3}s/.test(l)); + // 1 request + 1 response. + expect(frameLines).toHaveLength(2); + expect(out).toContain("tools/call"); + // The get_file call should not appear in the timeline frame lines. + expect(frameLines.every((l) => !l.includes("get_file"))).toBe(true); + }); + + it("matches all tools/call frames when filtering by the base method name", async () => { + const file = write("mixed3.jsonl", mixedFrames); + const out = await inspectTranscript(file, { filter: "tools/call" }); + + const frameLines = out.split("\n").filter((l) => /^\s*\d+\.\d{3}s/.test(l)); + // 2 calls, each with a response: 4 frame lines. + expect(frameLines).toHaveLength(4); + // ping and tools/list should not appear in timeline frame lines. + expect(frameLines.every((l) => !l.includes("ping") && !l.includes("tools/list"))).toBe(true); + }); + + it("shows no frame lines when filter matches nothing", async () => { + const file = write("nope.jsonl", mixedFrames); + const out = await inspectTranscript(file, { filter: "nonexistent_method" }); + + const frameLines = out.split("\n").filter((l) => /^\s*\d+\.\d{3}s/.test(l)); + expect(frameLines).toHaveLength(0); + // Summary footer still reflects the full transcript. + expect(out).toContain("8 frames"); + }); + + it("still shows full transcript stats in the footer when filter is active", async () => { + const file = write("stats.jsonl", mixedFrames); + const out = await inspectTranscript(file, { filter: "ping" }); + + // All 8 frames and 4 pairs reported in the footer. + expect(out).toContain("8 frames"); + expect(out).toContain("4 request/response pairs"); + // Full method list still present. + expect(out).toContain("tools/list: 1"); + expect(out).toContain("ping: 1"); + }); + + it("includes a filter note in the footer when filter is active", async () => { + const file = write("note.jsonl", mixedFrames); + const out = await inspectTranscript(file, { filter: "ping" }); + + expect(out).toContain("filter: ping"); + }); + + it("shows all frames when no filter is supplied (backward-compatible)", async () => { + const file = write("all.jsonl", mixedFrames); + const out = await inspectTranscript(file); + + const frameLines = out.split("\n").filter((l) => /^\s*\d+\.\d{3}s/.test(l)); + expect(frameLines).toHaveLength(8); + // No filter note in the footer. + expect(out).not.toContain("filter:"); + }); + + it("shows all frames when filter is an empty string", async () => { + const file = write("empty-filter.jsonl", mixedFrames); + const out = await inspectTranscript(file, { filter: "" }); + + // An empty string is falsy: the filter is treated as absent. + const frameLines = out.split("\n").filter((l) => /^\s*\d+\.\d{3}s/.test(l)); + expect(frameLines).toHaveLength(8); + expect(out).not.toContain("filter:"); + }); +});