diff --git a/src/mcp/github-inline-comment-server.ts b/src/mcp/github-inline-comment-server.ts index 535124f32..a023d9114 100644 --- a/src/mcp/github-inline-comment-server.ts +++ b/src/mcp/github-inline-comment-server.ts @@ -5,6 +5,7 @@ import { appendFileSync } from "fs"; import { z } from "zod"; import { createOctokit } from "../github/api/client"; import { sanitizeContent } from "../github/utils/sanitizer"; +import { removeBufferedComment } from "./inline-comment-buffer"; // Get repository and PR information from environment variables const REPO_OWNER = process.env.REPO_OWNER; @@ -180,6 +181,16 @@ server.tool( const result = await octokit.rest.pulls.createReviewComment(params); + // The comment is now live. Drop any buffered copy of it so the + // post-session replay step cannot post it a second time (the model often + // re-issues a buffered call with confirmed=true after the buffer reply). + if (CLASSIFY_ENABLED) { + removeBufferedComment( + { path, line, startLine, body: sanitizedBody }, + BUFFER_PATH, + ); + } + return { content: [ { diff --git a/src/mcp/inline-comment-buffer.ts b/src/mcp/inline-comment-buffer.ts new file mode 100644 index 000000000..4fb70caa2 --- /dev/null +++ b/src/mcp/inline-comment-buffer.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; + +export type BufferedCommentMatch = { + path: string; + line?: number; + startLine?: number; + body: string; +}; + +/** + * Remove any buffered inline comment that matches an already-posted comment. + * + * When a comment is posted live (confirmed=true), an earlier buffered copy of + * the same comment must be dropped so the post-session replay step does not + * post it a second time. The model frequently re-issues a buffered call with + * confirmed=true after reading the "Set confirmed=true to post immediately" + * reply; previously the original buffered entry was left behind and replayed, + * producing duplicate inline comments. + * + * Entries are matched on path, line, startLine and body. Lines that cannot be + * parsed are kept untouched. + */ +export function removeBufferedComment( + match: BufferedCommentMatch, + bufferPath: string, +): void { + if (!existsSync(bufferPath)) { + return; + } + + const remaining = readFileSync(bufferPath, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .filter((line) => { + let entry: BufferedCommentMatch; + try { + entry = JSON.parse(line); + } catch { + // Keep anything we cannot parse rather than silently dropping it. + return true; + } + const isSameComment = + entry.path === match.path && + entry.line === match.line && + entry.startLine === match.startLine && + entry.body === match.body; + return !isSameComment; + }); + + writeFileSync( + bufferPath, + remaining.length > 0 ? remaining.join("\n") + "\n" : "", + ); +} diff --git a/test/inline-comment-buffer.test.ts b/test/inline-comment-buffer.test.ts new file mode 100644 index 000000000..073148690 --- /dev/null +++ b/test/inline-comment-buffer.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { removeBufferedComment } from "../src/mcp/inline-comment-buffer"; + +describe("removeBufferedComment", () => { + let dir: string; + let bufferPath: string; + + const entryA = { + ts: "2026-06-13T00:00:00.000Z", + path: "src/index.ts", + line: 10, + startLine: undefined, + side: "RIGHT", + body: "Comment A", + }; + const entryB = { + ts: "2026-06-13T00:00:01.000Z", + path: "src/other.ts", + line: 20, + startLine: undefined, + side: "RIGHT", + body: "Comment B", + }; + + const writeBuffer = (entries: object[]): void => { + writeFileSync( + bufferPath, + entries.map((e) => JSON.stringify(e)).join("\n") + "\n", + ); + }; + + const readBuffer = (): Array<{ body: string }> => { + if (!existsSync(bufferPath)) { + return []; + } + return readFileSync(bufferPath, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line)); + }; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "inline-buffer-")); + bufferPath = join(dir, "buffer.jsonl"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("removes the matching buffered entry and keeps the others", () => { + writeBuffer([entryA, entryB]); + + removeBufferedComment( + { + path: "src/index.ts", + line: 10, + startLine: undefined, + body: "Comment A", + }, + bufferPath, + ); + + const remaining = readBuffer(); + expect(remaining.map((e) => e.body)).toEqual(["Comment B"]); + }); + + it("removes every copy when the same comment was buffered more than once", () => { + writeBuffer([entryA, entryA, entryB]); + + removeBufferedComment( + { + path: "src/index.ts", + line: 10, + startLine: undefined, + body: "Comment A", + }, + bufferPath, + ); + + expect(readBuffer().map((e) => e.body)).toEqual(["Comment B"]); + }); + + it("leaves the buffer untouched when nothing matches", () => { + writeBuffer([entryA, entryB]); + + removeBufferedComment( + { + path: "src/index.ts", + line: 999, + startLine: undefined, + body: "Comment A", + }, + bufferPath, + ); + + expect(readBuffer().map((e) => e.body)).toEqual(["Comment A", "Comment B"]); + }); + + it("does nothing when the buffer file does not exist", () => { + expect(() => + removeBufferedComment( + { path: "src/index.ts", line: 10, body: "Comment A" }, + bufferPath, + ), + ).not.toThrow(); + expect(existsSync(bufferPath)).toBe(false); + }); + + it("keeps lines that cannot be parsed as JSON", () => { + writeFileSync( + bufferPath, + ["not json", JSON.stringify(entryA)].join("\n") + "\n", + ); + + removeBufferedComment( + { + path: "src/index.ts", + line: 10, + startLine: undefined, + body: "Comment A", + }, + bufferPath, + ); + + const raw = readFileSync(bufferPath, "utf8"); + expect(raw).toContain("not json"); + expect(raw).not.toContain("Comment A"); + }); +});