utils/activity.test.ts
Rama main · réplica de lectura del repositorio original.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createProcessOutputActivityTimeout, isActivityNoise } from "./activity.ts";
describe("isActivityNoise", () => {
it("flags empty and whitespace-only chunks as noise", () => {
expect(isActivityNoise("")).toBe(true);
expect(isActivityNoise(" \n\t\n")).toBe(true);
});
it("flags pure mcp-proxy reconnect chatter as noise", () => {
expect(
isActivityNoise("[mcp-proxy] establishing new SSE stream for session ID abc-123\n")
).toBe(true);
expect(
isActivityNoise(
"[mcp-proxy] establishing new SSE stream for session ID a\n[mcp-proxy] received delete request\n"
)
).toBe(true);
});
it("flags provider-error retry lines as noise", () => {
expect(isActivityNoise("» provider error detected (rate_limit): ...\n")).toBe(true);
});
it("treats real agent output as activity", () => {
expect(isActivityNoise('{"type":"tool_use","id":"toolu_01"}\n')).toBe(false);
expect(isActivityNoise("Leaping into action...\n")).toBe(false);
});
it("treats mixed chunks (some noise + some real output) as activity", () => {
const mixed =
"[mcp-proxy] establishing new SSE stream for session ID abc\n" +
'{"type":"assistant_message"}\n';
expect(isActivityNoise(mixed)).toBe(false);
});
it("accepts Buffer input", () => {
expect(isActivityNoise(Buffer.from("[mcp-proxy] received delete request\n"))).toBe(true);
expect(isActivityNoise(Buffer.from('{"type":"tool_use"}\n'))).toBe(false);
});
it("flags chunks with only noise + blank lines as noise", () => {
const noiseWithBlanks =
"\n[mcp-proxy] establishing new SSE stream for session ID abc\n\n" +
"[mcp-proxy] received delete request\n\n";
expect(isActivityNoise(noiseWithBlanks)).toBe(true);
});
it("does not match the noise pattern mid-line", () => {
// `[mcp-proxy]` must anchor at start; embedded in agent output it's activity
expect(isActivityNoise("agent said: [mcp-proxy] was there\n")).toBe(false);
expect(isActivityNoise("context: provider error detected in log\n")).toBe(false);
});
it("flags debug-timestamp-prefixed noise lines", () => {
expect(
isActivityNoise("[2026-04-18T17:00:00.000Z] [mcp-proxy] establishing new SSE stream\n")
).toBe(true);
expect(
isActivityNoise("[2026-04-18T17:00:00.000Z] » provider error detected (rate_limit)\n")
).toBe(true);
});
it("flags our own monitor debug output (local-debug format)", () => {
// subprocess.ts's spawn activity check fires every 5s when debug is on;
// without this filter the outer timer would be reset each interval and
// the agent-hang detection (#12) silently fails in debug-enabled runs.
expect(
isActivityNoise(
"[2026-04-18T17:00:00.000Z] [DEBUG] spawn activity check: pid=123 idle=5000ms / 300000ms\n"
)
).toBe(true);
expect(
isActivityNoise(
"[2026-04-18T17:00:00.000Z] [DEBUG] spawn activity timer: pid=123 cmd=claude timeout=300000ms\n"
)
).toBe(true);
expect(
isActivityNoise(
"[2026-04-18T17:00:00.000Z] [DEBUG] process activity check: idle=120ms / 300000ms\n"
)
).toBe(true);
});
it("flags our own monitor debug output (GH-runner-debug ::debug:: format)", () => {
expect(isActivityNoise("::debug::spawn activity check: pid=123 idle=5000ms / 300000ms\n")).toBe(
true
);
expect(isActivityNoise("::debug::process activity check: idle=120ms / 300000ms\n")).toBe(true);
});
it("does not blanket-filter other debug-prefixed lines", () => {
// the filter is scoped to our own monitor diagnostics so genuine agent
// output that coincidentally starts with [DEBUG] still counts as activity.
expect(isActivityNoise("[2026-04-18T17:00:00.000Z] [DEBUG] git auth server listening\n")).toBe(
false
);
expect(isActivityNoise("::debug::agent stream chunk\n")).toBe(false);
});
});
describe("createProcessOutputActivityTimeout (debug-mode feedback loop)", () => {
// the monitor's own periodic diagnostic log used to travel through the
// wrapped process.stdout.write — in debug mode that meant the interval
// callback kept resetting the activity timer, so the timeout could never
// fire. guard against that regression by running the monitor under a
// simulated debug env with a tight timeout and confirming it still rejects.
const previousStepDebug = process.env.ACTIONS_STEP_DEBUG;
beforeEach(() => {
process.env.ACTIONS_STEP_DEBUG = "true";
});
afterEach(() => {
if (previousStepDebug === undefined) delete process.env.ACTIONS_STEP_DEBUG;
else process.env.ACTIONS_STEP_DEBUG = previousStepDebug;
});
it("still times out in debug mode even though the monitor emits periodic diagnostics", async () => {
const timeout = createProcessOutputActivityTimeout({
timeoutMs: 150,
checkIntervalMs: 20,
});
try {
await expect(timeout.promise).rejects.toThrow(/activity timeout/);
} finally {
timeout.stop();
}
});
});
describe("createProcessOutputActivityTimeout forceReject / stop disarming", () => {
// main.ts arms a 5min safety-net timer on inner-activity kill that later
// calls forceReject. when the agent succeeds first, main.ts calls stop().
// stop() must disarm forceReject — otherwise a late safety-net fire would
// reject a promise nothing is awaiting, re-creating the #12 zombie-run
// shape (unhandledRejection) or worse, failing a successful run.
it("forceReject rejects the promise with the given reason", async () => {
const timeout = createProcessOutputActivityTimeout({
timeoutMs: 60_000,
checkIntervalMs: 10_000,
});
try {
timeout.forceReject("safety-net fired");
await expect(timeout.promise).rejects.toThrow(/safety-net fired/);
} finally {
timeout.stop();
}
});
it("stop() disarms forceReject so a late safety-net fire is a no-op", async () => {
const timeout = createProcessOutputActivityTimeout({
timeoutMs: 60_000,
checkIntervalMs: 10_000,
});
// prevent unhandled-rejection noise if the assertion below ever regresses
timeout.promise.catch(() => {});
timeout.stop();
timeout.forceReject("late safety-net fire after run succeeded");
// race the promise against a short sleep; if forceReject reopened the
// rejection it would win the race. the sleep should always win.
const sentinel = Symbol("still-pending");
const winner = await Promise.race([
timeout.promise.then(
() => "resolved",
() => "rejected"
),
new Promise((resolve) => setTimeout(() => resolve(sentinel), 50)),
]);
expect(winner).toBe(sentinel);
});
it("forceReject is a no-op if the promise already rejected via the timer", async () => {
const timeout = createProcessOutputActivityTimeout({
timeoutMs: 60,
checkIntervalMs: 10,
});
try {
await expect(timeout.promise).rejects.toThrow(/activity timeout/);
// forceReject after timer rejection must not throw or double-reject
expect(() => timeout.forceReject("should be ignored")).not.toThrow();
} finally {
timeout.stop();
}
});
});