Skip to content

Commit 12e660f

Browse files
authored
fix: smarter Gemini reply extraction — skip narration, 1 para for short outputs (#190)
* fix: extract final answer from Gemini output in mail replies (ops-93) Gemini CLI dumps full chain-of-thought (YOLO banners, tool narration, reasoning) before the final response. Mail replies were sending the entire stream. - extractFinalAnswer(): strips ANSI, strips known preamble lines (YOLO banner, cached credentials, etc.), returns last 3 paragraphs - Applied in runGemini() before sendMail() - 4 unit tests covering: preamble stripping, last-paragraph selection, empty input, ANSI stripping 494/494 tests. * fix: inject ~/.tps/bin into Gemini subprocess PATH for gh-as access * fix: biome lint errors — ANSI regex suppression, remove unused swarn/serror * fix: smarter reply extraction — skip narration paragraphs, 1 para for short outputs
1 parent 89f0738 commit 12e660f

2 files changed

Lines changed: 38 additions & 6 deletions

File tree

packages/cli/src/utils/gemini-runtime.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ async function buildPrompt(message: MailMessage, config: GeminiConfig): Promise<
113113

114114
/**
115115
* Extract the final answer from Gemini output.
116-
* Strips YOLO banners, preamble, ANSI codes, then returns last 3 paragraphs.
116+
* Strips YOLO banners, preamble, ANSI codes, then returns the last paragraph
117+
* for short outputs (≤5 paragraphs) or last 3 for longer ones.
118+
* Narration paragraphs (starting with "I'll", "I've", "Okay", etc.) are
119+
* skipped when walking backward to find the final substantive reply.
117120
*/
118121
export function extractFinalAnswer(raw: string): string {
119122
const STRIP_PREFIXES = [
@@ -122,6 +125,7 @@ export function extractFinalAnswer(raw: string): string {
122125
"All tool calls will be automatically approved.",
123126
"missing pgrep output",
124127
];
128+
const NARRATION_RE = /^(I['\u2019]ll|I['\u2019]ve|Okay[,.]|Now I|Let me|I will|I need|I should|I am going|I can|I have to|First,|Next,|Then,|Now,|Finally,)/i;
125129
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping requires ESC
126130
const stripped = raw.replace(/\u001b\[[0-9;]*m/g, "");
127131
const lines = stripped.split("\n").filter(
@@ -130,7 +134,12 @@ export function extractFinalAnswer(raw: string): string {
130134
const cleaned = lines.join("\n").trim();
131135
const paragraphs = cleaned.split(/\n\n+/).map((p) => p.trim()).filter(Boolean);
132136
if (paragraphs.length === 0) return cleaned;
133-
return paragraphs.slice(-3).join("\n\n");
137+
// Walk backward to find last non-narration paragraph
138+
let end = paragraphs.length - 1;
139+
while (end > 0 && NARRATION_RE.test(paragraphs[end])) end--;
140+
// For short outputs take just that paragraph; longer outputs take up to 3
141+
const count = paragraphs.length > 5 ? 3 : 1;
142+
return paragraphs.slice(Math.max(0, end - count + 1), end + 1).join("\n\n");
134143
}
135144

136145
async function runGemini(message: MailMessage, config: GeminiConfig, taskTimeoutMs: number): Promise<string> {

packages/cli/test/gemini-extract.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The PR looks good. Changes are secure.`;
1414
expect(result).toContain("The PR looks good");
1515
});
1616

17-
test("returns last paragraph(s) as final answer", () => {
17+
test("short output — returns single final paragraph, skips narration", () => {
1818
const raw = `YOLO mode is enabled. All tool calls will be automatically approved.
1919
2020
I'll analyze the code.
@@ -24,17 +24,40 @@ I've completed the analysis.
2424
**Summary:** The changes are correct and secure. No issues found.`;
2525
const result = extractFinalAnswer(raw);
2626
expect(result).toContain("Summary");
27-
expect(result).not.toContain("YOLO");
27+
expect(result).not.toContain("I'll analyze");
28+
expect(result).not.toContain("I've completed");
29+
});
30+
31+
test("long output (>5 paragraphs) — returns up to 3 concluding paragraphs", () => {
32+
const paragraphs = [
33+
"I'll start reviewing.",
34+
"Checking imports.",
35+
"Looking at the logic.",
36+
"Verifying tests.",
37+
"Reviewing security.",
38+
"All changes look correct.",
39+
"No vulnerabilities found.",
40+
];
41+
const raw = paragraphs.join("\n\n");
42+
const result = extractFinalAnswer(raw);
43+
expect(result).toContain("No vulnerabilities found");
44+
expect(result).not.toContain("I'll start");
2845
});
2946

3047
test("handles empty input", () => {
3148
expect(extractFinalAnswer("")).toBe("");
3249
});
3350

3451
test("strips ANSI color codes", () => {
35-
const raw = "\x1b[32mGreen text\x1b[0m\n\nFinal answer.";
52+
const raw = "\u001b[32mGreen text\u001b[0m\n\nFinal answer.";
3653
const result = extractFinalAnswer(raw);
37-
expect(result).not.toContain("\x1b");
54+
expect(result).not.toContain("\u001b");
3855
expect(result).toContain("Final answer");
3956
});
57+
58+
test("single paragraph — returns it regardless of narration prefix", () => {
59+
const raw = "I've completed the task successfully.";
60+
const result = extractFinalAnswer(raw);
61+
expect(result).toBe("I've completed the task successfully.");
62+
});
4063
});

0 commit comments

Comments
 (0)