diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index e5d8848d6c..74051ab469 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -63,6 +63,59 @@ describe("buildHeartbeatRunIssueComment", () => { it("returns null when there is no usable final text", () => { expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull(); }); + + it("suppresses raw transcript when the summary reads like inter-tool narration", () => { + const narration = + "Let me check the issue thread first. I'll fetch the latest comments and then decide what to do next."; + const comment = buildHeartbeatRunIssueComment({ summary: narration }); + + expect(comment).not.toContain("Let me check"); + expect(comment).toContain("did not post a summary comment"); + }); + + it("suppresses each narration opener variant", () => { + for (const opener of [ + "Let me look into this.", + "I'll start by reading the file.", + "I need to inspect the config.", + "I can see the problem now.", + "Looking at the logs, the error is clear.", + "Fetching the run details from the API.", + "Checking the current branch state.", + "First, I will reproduce the bug.", + "I’m going to trace the fallback path.", + "Now I'll push the follow-up commit.", + "Next, I'll re-run the suite.", + ]) { + expect(buildHeartbeatRunIssueComment({ summary: opener })).toContain( + "did not post a summary comment", + ); + } + }); + + it("does not treat the apostrophe opener as a regex wildcard", () => { + // Prior regex used `i.ll` where `.` matched any char; these must pass through. + for (const summary of ["Iall greetings logged.", "I-ll formatting kept."]) { + expect(buildHeartbeatRunIssueComment({ summary })).toBe(summary); + } + }); + + it("suppresses over-long fallback summaries even without a narration opener", () => { + const comment = buildHeartbeatRunIssueComment({ summary: "x".repeat(1201) }); + expect(comment).toContain("did not post a summary comment"); + expect(comment).not.toContain("xxxx"); + }); + + it("posts a clean, in-length summary with no narration opener normally", () => { + const summary = "## Summary\n\n- fixed the fallback gate\n- added regression tests"; + expect(buildHeartbeatRunIssueComment({ summary })).toBe(summary); + }); + + it("posts a summary exactly at the length cap", () => { + const summary = "S" + "x".repeat(1199); + expect(summary.length).toBe(1200); + expect(buildHeartbeatRunIssueComment({ summary })).toBe(summary); + }); }); describe("mergeHeartbeatRunResultJson", () => { diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 479957dce2..cdd1bdeca9 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -92,6 +92,19 @@ export function summarizeHeartbeatRunResultJson( return Object.keys(summary).length > 0 ? summary : null; } +// The fallback comment is only posted when a run ends without the agent posting +// its own comment via the API. In that case `resultJson.summary` can be raw +// inter-tool narration (assistantTexts concatenated by the adapter), which must +// never be published verbatim to the board — see BRO-1507 / BRO-1516. +export const MAX_FALLBACK_COMMENT_CHARS = 1200; +// Apostrophes are matched as a character class so both the straight (') and +// curly (’) forms count — agents emit either. Openers are narration phrases a +// declarative status summary would not begin with ("Fixed X", "13/13 pass"). +const NARRATION_OPENERS = + /^(let me\b|i['’]ll\b|i['’]m going\b|i need to\b|i can see\b|now i['’]ll\b|next,? i['’]ll\b|looking at\b|fetching\b|checking\b|first,)/i; +const FALLBACK_WITHHELD_COMMENT = + "Run completed. Agent did not post a summary comment this run (transcript withheld — see run log)."; + export function buildHeartbeatRunIssueComment( resultJson: Record | null | undefined, ): string | null { @@ -99,10 +112,17 @@ export function buildHeartbeatRunIssueComment( return null; } - return ( + const text = readCommentText(resultJson.summary) ?? readCommentText(resultJson.result) - ?? readCommentText(resultJson.message) - ?? null - ); + ?? readCommentText(resultJson.message); + if (!text) { + return null; + } + + if (text.length > MAX_FALLBACK_COMMENT_CHARS || NARRATION_OPENERS.test(text)) { + return FALLBACK_WITHHELD_COMMENT; + } + + return text; }