mirror of
https://fastgit.cc/github.com/Yeachan-Heo/oh-my-claudecode
synced 2026-04-22 05:41:02 +08:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user