Prevent review-seed prompts from tripping tmux keyword alerts

Review-session steering prompts still surfaced raw outcome labels like approve/request-changes/BLOCKED in captured tmux tails, so downstream alerting saw the seed text before any real runtime state. Trim a narrow seeded-review prefix from parsed tails and apply the cleaned tail before notification/OpenClaw payloads are emitted, while preserving real runtime failures and BLOCKED lines.

Constraint: External tmux alerting depends on payload tail text, so the fix must happen before notifications/OpenClaw dispatch
Rejected: Disable review keywords globally | would hide real blocker/runtime signals
Rejected: Only clean formatter output | raw payload consumers would still see seeded prompt noise
Confidence: high
Scope-risk: narrow
Directive: Keep seeded review outcome menus out of tmux-tail payloads unless they are explicitly separated from runtime output
Tested: npx vitest run src/notifications/__tests__/formatter.test.ts src/openclaw/__tests__/index.test.ts src/notifications/__tests__/notify-registry-integration.test.ts
Tested: npx eslint src/notifications/formatter.ts src/notifications/index.ts src/openclaw/index.ts src/notifications/__tests__/formatter.test.ts src/openclaw/__tests__/index.test.ts
Tested: npx tsc --noEmit --pretty false --project tsconfig.json
Tested: npm run build
Not-tested: Live clawhip tmux keyword alerts in a real PR review session
This commit is contained in:
Codex Review
2026-04-11 05:28:34 +00:00
parent 5de9d4ba07
commit 40e53bd884
5 changed files with 140 additions and 4 deletions

View File

@@ -321,6 +321,45 @@ describe("parseTmuxTail noise filters", () => {
].join("\n");
expect(parseTmuxTail(input)).toBe("Build complete\nTests passed: 42");
});
it("drops seeded PR review outcome instructions that would trip keyword alerts", () => {
const input = [
"Review PR #2498 and reply with exactly one verdict:",
"- approve",
"- request-changes",
"- follow-up-fix",
"- BLOCKED",
].join("\n");
expect(parseTmuxTail(input)).toBe("");
});
it("prefers later runtime output over seeded PR review instructions", () => {
const input = [
"Review PR #2498 and reply with exactly one verdict:",
"- approve",
"- request-changes",
"- follow-up-fix",
"- BLOCKED",
"Traceback (most recent call last):",
"ValueError: boom",
"BLOCKED: evaluator crashed at runtime",
].join("\n");
expect(parseTmuxTail(input)).toBe(
"Traceback (most recent call last):\nValueError: boom\nBLOCKED: evaluator crashed at runtime",
);
});
it("preserves real runtime blocked output when no seeded review prefix exists", () => {
const input = [
"BLOCKED: missing baseline snapshot",
"Traceback (most recent call last):",
"RuntimeError: boom",
].join("\n");
expect(parseTmuxTail(input)).toBe(input);
});
});
describe("tmuxTail in formatters", () => {

View File

@@ -192,10 +192,65 @@ const BARE_PROMPT_RE = /^[>$%#]+$/;
/** Minimum ratio of alphanumeric characters for a line to be "meaningful". */
const MIN_ALNUM_RATIO = 0.15;
/** Review-session seed prompt outcome keywords that cause tmux alert noise. */
const REVIEW_SEED_OUTCOME_PATTERNS = [
{ key: "approve", pattern: /\bapprove\b/i },
{ key: "request-changes", pattern: /\brequest[- ]changes\b/i },
{ key: "follow-up-fix", pattern: /\bfollow[- ]up[- ]fix\b/i },
{ key: "blocked", pattern: /\bblocked\b/i },
] as const;
/** Instructional phrasing commonly found in seeded review prompts. */
const REVIEW_SEED_CUE_RE =
/\b(review|verdict|respond|reply|return|output|classification|classify|decision|choose|label)\b/i;
/** Continuation markers for bullets / enumerated option lines in seeded prompts. */
const REVIEW_SEED_LIST_RE = /^(?:[-*•]|\d+[.)]|[A-Z][A-Z_-]+:|\([a-z0-9]+\))/;
/** Default maximum number of meaningful lines to include in a notification.
* Matches DEFAULT_TMUX_TAIL_LINES in config.ts. */
const DEFAULT_MAX_TAIL_LINES = 15;
function extractReviewSeedOutcomeKeys(line: string): string[] {
return REVIEW_SEED_OUTCOME_PATTERNS
.filter(({ pattern }) => pattern.test(line))
.map(({ key }) => key);
}
function trimReviewSeedPrefix(lines: string[]): string[] {
if (lines.length === 0) return lines;
const prefix = lines.slice(0, 10);
const distinctOutcomes = new Set<string>();
let hasCue = false;
let candidateEnd = -1;
for (let index = 0; index < prefix.length; index += 1) {
const line = prefix[index]!;
const outcomeKeys = extractReviewSeedOutcomeKeys(line);
const isCueLine = REVIEW_SEED_CUE_RE.test(line);
const isSeedLine =
outcomeKeys.length > 0 ||
isCueLine ||
(candidateEnd >= 0 && REVIEW_SEED_LIST_RE.test(line));
outcomeKeys.forEach((key) => distinctOutcomes.add(key));
if (isCueLine) hasCue = true;
if (isSeedLine) {
candidateEnd = index;
continue;
}
if (candidateEnd >= 0) break;
}
const qualifies =
candidateEnd >= 0 &&
(distinctOutcomes.size >= 3 || (distinctOutcomes.size >= 2 && hasCue));
if (!qualifies) return lines;
return lines.slice(candidateEnd + 1);
}
/**
* Parse raw tmux output into clean, human-readable lines.
* - Strips ANSI escape codes
@@ -225,7 +280,8 @@ export function parseTmuxTail(raw: string, maxLines: number = DEFAULT_MAX_TAIL_L
meaningful.push(stripped.trimEnd());
}
return meaningful.slice(-maxLines).join("\n");
const trimmed = trimReviewSeedPrefix(meaningful);
return trimmed.slice(-maxLines).join("\n");
}
/**

View File

@@ -50,6 +50,7 @@ export {
formatSessionIdle,
formatAskUserQuestion,
formatAgentCall,
parseTmuxTail,
} from "./formatter.js";
export {
getCurrentTmuxSession,
@@ -106,7 +107,7 @@ import {
isEventAllowedByVerbosity,
shouldIncludeTmuxTail,
} from "./config.js";
import { formatNotification } from "./formatter.js";
import { formatNotification, parseTmuxTail } from "./formatter.js";
import { dispatchNotifications } from "./dispatcher.js";
import { getCurrentTmuxSession } from "./tmux.js";
import { getHookConfig, resolveEventTemplate } from "./hook-config.js";
@@ -188,7 +189,10 @@ export async function notify(
"../features/rate-limit-wait/tmux-detector.js"
);
const tailLines = getTmuxTailLines(config);
const tail = capturePaneContent(payload.tmuxPaneId, tailLines);
const tail = parseTmuxTail(
capturePaneContent(payload.tmuxPaneId, tailLines),
tailLines,
);
if (tail) {
payload.tmuxTail = tail;
payload.maxTailLines = tailLines;

View File

@@ -9,6 +9,11 @@ vi.mock("../../notifications/tmux.js", () => ({
getCurrentTmuxSession: () => mockGetCurrentTmuxSession(),
}));
const mockCapturePaneContent = vi.fn<(paneId: string, lines?: number) => string>(() => "");
vi.mock("../../features/rate-limit-wait/tmux-detector.js", () => ({
capturePaneContent: (paneId: string, lines?: number) => mockCapturePaneContent(paneId, lines),
}));
// Mock config and dispatcher modules
vi.mock("../config.js", () => ({
getOpenClawConfig: vi.fn(),
@@ -65,6 +70,7 @@ describe("wakeOpenClaw", () => {
statusCode: 200,
});
mockGetCurrentTmuxSession.mockReturnValue(null);
mockCapturePaneContent.mockReturnValue("");
});
afterEach(() => {
@@ -106,6 +112,35 @@ describe("wakeOpenClaw", () => {
expect(payload.instruction).toContain("myproject"); // interpolated
});
it("sanitizes tmux tail before sending stop payloads", async () => {
mockCapturePaneContent.mockReturnValue([
"Review PR #2498 and reply with exactly one verdict:",
"- approve",
"- request-changes",
"- follow-up-fix",
"- BLOCKED",
"Traceback (most recent call last):",
"RuntimeError: boom",
"BLOCKED: runtime failure",
].join("\n"));
vi.stubEnv("TMUX", "/tmp/tmux-1000/default,123,0");
vi.stubEnv("TMUX_PANE", "%7");
await wakeOpenClaw("stop", {
sessionId: "sid-stop",
projectPath: "/home/user/myproject",
});
expect(mockCapturePaneContent).toHaveBeenCalledWith("%7", 15);
const payload = vi.mocked(wakeGateway).mock.calls[0]?.[2];
expect(payload.tmuxTail).toBe(
"Traceback (most recent call last):\nRuntimeError: boom\nBLOCKED: runtime failure",
);
expect(payload.tmuxTail).not.toContain("approve");
expect(payload.tmuxTail).not.toContain("request-changes");
expect(payload.tmuxTail).not.toContain("follow-up-fix");
});
it("uses a single timestamp in both template variables and payload", async () => {
// Spy on Date.prototype.toISOString to track calls
const mockTimestamp = "2026-02-25T12:00:00.000Z";

View File

@@ -33,6 +33,7 @@ import { wakeGateway, wakeCommandGateway, interpolateInstruction, isCommandGatew
import { buildOpenClawSignal } from "./signal.js";
import { shouldCollapseOpenClawBurst } from "./dedupe.js";
import { basename } from "path";
import { parseTmuxTail } from "../notifications/formatter.js";
import { getCurrentTmuxSession } from "../notifications/tmux.js";
/** Whether debug logging is enabled */
@@ -118,7 +119,8 @@ export async function wakeOpenClaw(
const { capturePaneContent } = await import("../features/rate-limit-wait/tmux-detector.js");
const paneId = process.env.TMUX_PANE;
if (paneId) {
tmuxTail = capturePaneContent(paneId, 15) ?? undefined;
const captured = capturePaneContent(paneId, 15);
tmuxTail = parseTmuxTail(captured, 15) || undefined;
}
} catch {
// Non-blocking: tmux capture is best-effort