diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index 33cd57de6d..984b6e8aac 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -1,9 +1,16 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { isAbortTrigger, tryFastAbortFromMessage } from "./abort.js"; +import { + getAbortMemory, + getAbortMemorySizeForTest, + isAbortTrigger, + resetAbortMemoryForTest, + setAbortMemory, + tryFastAbortFromMessage, +} from "./abort.js"; import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./queue.js"; import { initSessionState } from "./session.js"; import { buildTestCtx } from "./test-ctx.js"; @@ -28,6 +35,10 @@ vi.mock("../../agents/subagent-registry.js", () => ({ })); describe("abort detection", () => { + afterEach(() => { + resetAbortMemoryForTest(); + }); + it("triggerBodyNormalized extracts /stop from RawBody for abort detection", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); const storePath = path.join(root, "sessions.json"); @@ -62,6 +73,24 @@ describe("abort detection", () => { expect(isAbortTrigger("/stop")).toBe(false); }); + it("removes abort memory entry when flag is reset", () => { + setAbortMemory("session-1", true); + expect(getAbortMemory("session-1")).toBe(true); + + setAbortMemory("session-1", false); + expect(getAbortMemory("session-1")).toBeUndefined(); + expect(getAbortMemorySizeForTest()).toBe(0); + }); + + it("caps abort memory tracking to a bounded max size", () => { + for (let i = 0; i < 2105; i += 1) { + setAbortMemory(`session-${i}`, true); + } + expect(getAbortMemorySizeForTest()).toBe(2000); + expect(getAbortMemory("session-0")).toBeUndefined(); + expect(getAbortMemory("session-2104")).toBe(true); + }); + it("fast-aborts even when text commands are disabled", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); const storePath = path.join(root, "sessions.json"); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 42b4f1708a..de2583c9d3 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -22,6 +22,7 @@ import { clearSessionQueues } from "./queue.js"; const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit", "interrupt"]); const ABORT_MEMORY = new Map(); +const ABORT_MEMORY_MAX = 2000; export function isAbortTrigger(text?: string): boolean { if (!text) { @@ -32,11 +33,51 @@ export function isAbortTrigger(text?: string): boolean { } export function getAbortMemory(key: string): boolean | undefined { - return ABORT_MEMORY.get(key); + const normalized = key.trim(); + if (!normalized) { + return undefined; + } + return ABORT_MEMORY.get(normalized); +} + +function pruneAbortMemory(): void { + if (ABORT_MEMORY.size <= ABORT_MEMORY_MAX) { + return; + } + const excess = ABORT_MEMORY.size - ABORT_MEMORY_MAX; + let removed = 0; + for (const entryKey of ABORT_MEMORY.keys()) { + ABORT_MEMORY.delete(entryKey); + removed += 1; + if (removed >= excess) { + break; + } + } } export function setAbortMemory(key: string, value: boolean): void { - ABORT_MEMORY.set(key, value); + const normalized = key.trim(); + if (!normalized) { + return; + } + if (!value) { + ABORT_MEMORY.delete(normalized); + return; + } + // Refresh insertion order so active keys are less likely to be evicted. + if (ABORT_MEMORY.has(normalized)) { + ABORT_MEMORY.delete(normalized); + } + ABORT_MEMORY.set(normalized, true); + pruneAbortMemory(); +} + +export function getAbortMemorySizeForTest(): number { + return ABORT_MEMORY.size; +} + +export function resetAbortMemoryForTest(): void { + ABORT_MEMORY.clear(); } export function formatAbortReplyText(stoppedSubagents?: number): string {