diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a779d8531..fc66c785a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai - Agents/Tools/exec: treat normal non-zero exit codes as completed and append the exit code to tool output to avoid false tool-failure warnings. (#18425) - Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc. - Agents/Hooks: preserve the `before_tool_call` wrapped-marker across abort-signal tool wrapping so the hook runs once per tool call in normal agent sessions. (#16852) Thanks @sreuter. +- Agents/Tests: add `before_message_write` persistence regression coverage for block/mutate behavior (including synthetic tool-result flushes) and thrown-hook fallback persistence. (#18197) Thanks @shakkernerd - Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. - Agents/Image tool: replace Anthropic-incompatible union schema with explicit `image` (single) and `images` (multi) parameters, keeping tool schemas `anyOf`/`oneOf`/`allOf`-free while preserving multi-image analysis support. (#18551, #18566) Thanks @aldoeliacim. - Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost. diff --git a/src/agents/session-tool-result-guard.e2e.test.ts b/src/agents/session-tool-result-guard.e2e.test.ts index 79fbf4cc5f..4cce7ad41b 100644 --- a/src/agents/session-tool-result-guard.e2e.test.ts +++ b/src/agents/session-tool-result-guard.e2e.test.ts @@ -261,6 +261,63 @@ describe("installSessionToolResultGuard", () => { expect(text).toBe(originalText); }); + it("blocks persistence when before_message_write returns block=true", () => { + const sm = SessionManager.inMemory(); + installSessionToolResultGuard(sm, { + beforeMessageWriteHook: () => ({ block: true }), + }); + + sm.appendMessage( + asAppendMessage({ + role: "user", + content: "hidden", + timestamp: Date.now(), + }), + ); + + expect(getPersistedMessages(sm)).toHaveLength(0); + }); + + it("applies before_message_write message mutations before persistence", () => { + const sm = SessionManager.inMemory(); + installSessionToolResultGuard(sm, { + beforeMessageWriteHook: ({ message }) => { + if ((message as { role?: string }).role !== "toolResult") { + return undefined; + } + return { + message: { + ...(message as unknown as Record), + content: [{ type: "text", text: "rewritten by hook" }], + } as unknown as AgentMessage, + }; + }, + }); + + appendToolResultText(sm, "original"); + + const text = getToolResultText(getPersistedMessages(sm)); + expect(text).toBe("rewritten by hook"); + }); + + it("applies before_message_write to synthetic tool-result flushes", () => { + const sm = SessionManager.inMemory(); + const guard = installSessionToolResultGuard(sm, { + beforeMessageWriteHook: ({ message }) => { + if ((message as { role?: string }).role !== "toolResult") { + return undefined; + } + return { block: true }; + }, + }); + + sm.appendMessage(toolCallMessage); + guard.flushPendingToolResults(); + + const messages = getPersistedMessages(sm); + expect(messages.map((m) => m.role)).toEqual(["assistant"]); + }); + it("applies message persistence transform to user messages", () => { const sm = SessionManager.inMemory(); installSessionToolResultGuard(sm, { diff --git a/src/agents/session-tool-result-guard.tool-result-persist-hook.e2e.test.ts b/src/agents/session-tool-result-guard.tool-result-persist-hook.e2e.test.ts index 93c67bf40b..f85332b4db 100644 --- a/src/agents/session-tool-result-guard.tool-result-persist-hook.e2e.test.ts +++ b/src/agents/session-tool-result-guard.tool-result-persist-hook.e2e.test.ts @@ -129,3 +129,51 @@ describe("tool_result_persist hook", () => { expect(toolResult.details).toBeTruthy(); }); }); + +describe("before_message_write hook", () => { + it("continues persistence when a before_message_write hook throws", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-before-write-")); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + + const plugin = writeTempPlugin({ + dir: tmp, + id: "before-write-throws", + body: `export default { id: "before-write-throws", register(api) { + api.on("before_message_write", () => { + throw new Error("boom"); + }, { priority: 10 }); +} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: tmp, + config: { + plugins: { + load: { paths: [plugin] }, + allow: ["before-write-throws"], + }, + }, + }); + initializeGlobalHookRunner(registry); + + const sm = guardSessionManager(SessionManager.inMemory(), { + agentId: "main", + sessionKey: "main", + }); + const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void; + appendMessage({ + role: "user", + content: "hello", + timestamp: Date.now(), + } as AgentMessage); + + const messages = sm + .getEntries() + .filter((e) => e.type === "message") + .map((e) => (e as { message: AgentMessage }).message); + + expect(messages).toHaveLength(1); + expect(messages[0]?.role).toBe("user"); + }); +});