From 1fbe83d09f9a0a34321b52ff53c6048b59cefd65 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 13:45:00 +0100 Subject: [PATCH] fix: keep link understanding from dropping replies --- CHANGELOG.md | 1 + docs/automation/hooks.md | 34 +++++------ .../reply/get-reply.message-hooks.test.ts | 60 +++++++++++++++++++ src/auto-reply/reply/get-reply.ts | 30 ++++++++-- src/infra/tsdown-config.test.ts | 2 + tsdown.config.ts | 2 + 6 files changed, 106 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7874fd0b16021..eaf33fdeba075b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111. - Nodes/CLI: add `openclaw nodes remove --node ` and `node.pair.remove` so stale gateway-owned node pairing records can be cleaned without hand-editing state files. Thanks @openclaw. - Docker: install the CA certificate bundle in the slim runtime image so HTTPS calls from containerized gateways no longer fail TLS setup after the `bookworm-slim` base switch. Fixes #72787. Thanks @ryuhaneul. - Providers/OpenRouter: remove retired Hunter Alpha and Healer Alpha static catalog rows and disable proxy reasoning injection for stale Hunter Alpha configs, so replies are not hidden when OpenRouter returns answer text in reasoning fields. Fixes #43942. Thanks @EvanDataForge. diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 39a83fd5681925..36d017b9371d1d 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -33,23 +33,23 @@ openclaw hooks info session-memory ## Event types -| Event | When it fires | -| ------------------------ | ------------------------------------------------ | -| `command:new` | `/new` command issued | -| `command:reset` | `/reset` command issued | -| `command:stop` | `/stop` command issued | -| `command` | Any command event (general listener) | -| `session:compact:before` | Before compaction summarizes history | -| `session:compact:after` | After compaction completes | -| `session:patch` | When session properties are modified | -| `agent:bootstrap` | Before workspace bootstrap files are injected | -| `gateway:startup` | After channels start and hooks are loaded | -| `gateway:shutdown` | When gateway shutdown begins | -| `gateway:pre-restart` | Before an expected gateway restart | -| `message:received` | Inbound message from any channel | -| `message:transcribed` | After audio transcription completes | -| `message:preprocessed` | After all media and link understanding completes | -| `message:sent` | Outbound message delivered | +| Event | When it fires | +| ------------------------ | ---------------------------------------------------------- | +| `command:new` | `/new` command issued | +| `command:reset` | `/reset` command issued | +| `command:stop` | `/stop` command issued | +| `command` | Any command event (general listener) | +| `session:compact:before` | Before compaction summarizes history | +| `session:compact:after` | After compaction completes | +| `session:patch` | When session properties are modified | +| `agent:bootstrap` | Before workspace bootstrap files are injected | +| `gateway:startup` | After channels start and hooks are loaded | +| `gateway:shutdown` | When gateway shutdown begins | +| `gateway:pre-restart` | Before an expected gateway restart | +| `message:received` | Inbound message from any channel | +| `message:transcribed` | After audio transcription completes | +| `message:preprocessed` | After media and link preprocessing completes or is skipped | +| `message:sent` | Outbound message delivered | ## Writing hooks diff --git a/src/auto-reply/reply/get-reply.message-hooks.test.ts b/src/auto-reply/reply/get-reply.message-hooks.test.ts index 5f6de6745ad6e7..e3961a4cc87584 100644 --- a/src/auto-reply/reply/get-reply.message-hooks.test.ts +++ b/src/auto-reply/reply/get-reply.message-hooks.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { logVerbose } from "../../globals.js"; import type { MsgContext } from "../templating.js"; import { withFastReplyConfig } from "./get-reply-fast-path.js"; import { @@ -74,6 +75,7 @@ describe("getReplyFromConfig message hooks", () => { mocks.triggerInternalHook.mockReset(); mocks.resolveReplyDirectives.mockReset(); mocks.initSessionState.mockReset(); + vi.mocked(logVerbose).mockReset(); mocks.applyMediaUnderstanding.mockImplementation(async (...args: unknown[]) => { const { ctx } = args[0] as { ctx: MsgContext }; @@ -198,4 +200,62 @@ describe("getReplyFromConfig message hooks", () => { expect(mocks.applyMediaUnderstanding).not.toHaveBeenCalled(); expect(mocks.applyLinkUnderstanding).not.toHaveBeenCalled(); }); + + it("continues dispatching when media understanding fails before reply routing", async () => { + mocks.applyMediaUnderstanding.mockRejectedValueOnce( + new Error("Cannot find module '/tmp/openclaw/dist/media-understanding/apply.runtime-old.js'"), + ); + + const reply = await getReplyFromConfig(buildCtx(), undefined, withFastReplyConfig({})); + + expect(reply).toEqual({ text: "ok" }); + expect(mocks.applyMediaUnderstanding).toHaveBeenCalledTimes(1); + expect(mocks.initSessionState).toHaveBeenCalledTimes(1); + expect(mocks.resolveReplyDirectives).toHaveBeenCalledTimes(1); + expect(mocks.createInternalHookEvent).toHaveBeenCalledTimes(1); + expect(mocks.createInternalHookEvent).toHaveBeenCalledWith( + "message", + "preprocessed", + "agent:main:telegram:-100123", + expect.any(Object), + ); + expect(logVerbose).toHaveBeenCalledWith( + expect.stringContaining("media understanding failed, proceeding with raw content"), + ); + }); + + it("continues dispatching URL messages when link understanding fails before reply routing", async () => { + mocks.applyLinkUnderstanding.mockRejectedValueOnce( + new Error("Cannot find module '/tmp/openclaw/dist/link-understanding/apply.runtime-old.js'"), + ); + + const reply = await getReplyFromConfig( + buildCtx({ + Body: "read https://example.test/page", + BodyForAgent: "read https://example.test/page", + RawBody: "read https://example.test/page", + CommandBody: "read https://example.test/page", + BodyForCommands: "read https://example.test/page", + MediaPath: undefined, + MediaUrl: undefined, + MediaPaths: undefined, + MediaUrls: undefined, + MediaTypes: undefined, + MediaType: undefined, + Sticker: undefined, + StickerMediaIncluded: undefined, + }), + undefined, + withFastReplyConfig({}), + ); + + expect(reply).toEqual({ text: "ok" }); + expect(mocks.applyMediaUnderstanding).not.toHaveBeenCalled(); + expect(mocks.applyLinkUnderstanding).toHaveBeenCalledTimes(1); + expect(mocks.initSessionState).toHaveBeenCalledTimes(1); + expect(mocks.resolveReplyDirectives).toHaveBeenCalledTimes(1); + expect(logVerbose).toHaveBeenCalledWith( + expect.stringContaining("link understanding failed, proceeding with raw content"), + ); + }); }); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 0ee4fa3dea6385..ae1ab3c745a7e8 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -10,6 +10,8 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js"; import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; import { type OpenClawConfig, getRuntimeConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; @@ -137,9 +139,17 @@ async function applyMediaUnderstandingIfNeeded(params: { if (!hasInboundMedia(params.ctx)) { return false; } - const { applyMediaUnderstanding } = await loadMediaUnderstandingApplyRuntime(); - await applyMediaUnderstanding(params); - return true; + try { + const { applyMediaUnderstanding } = await loadMediaUnderstandingApplyRuntime(); + await applyMediaUnderstanding(params); + return true; + } catch (err) { + mediaUnderstandingApplyRuntimePromise = null; + logVerbose( + `media understanding failed, proceeding with raw content: ${formatErrorMessage(err)}`, + ); + return false; + } } async function applyLinkUnderstandingIfNeeded(params: { @@ -149,9 +159,17 @@ async function applyLinkUnderstandingIfNeeded(params: { if (!hasLinkCandidate(params.ctx)) { return false; } - const { applyLinkUnderstanding } = await loadLinkUnderstandingApplyRuntime(); - await applyLinkUnderstanding(params); - return true; + try { + const { applyLinkUnderstanding } = await loadLinkUnderstandingApplyRuntime(); + await applyLinkUnderstanding(params); + return true; + } catch (err) { + linkUnderstandingApplyRuntimePromise = null; + logVerbose( + `link understanding failed, proceeding with raw content: ${formatErrorMessage(err)}`, + ); + return false; + } } export async function getReplyFromConfig( diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index f834b7ecc0e1c1..508e51330f4d5a 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -68,6 +68,8 @@ describe("tsdown config", () => { "agents/models-config.runtime", "subagent-registry.runtime", "agents/pi-model-discovery-runtime", + "link-understanding/apply.runtime", + "media-understanding/apply.runtime", "index", "commands/status.summary.runtime", "plugins/provider-discovery.runtime", diff --git a/tsdown.config.ts b/tsdown.config.ts index 7327921efd3244..5b83c633145f74 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -214,6 +214,8 @@ function buildCoreDistEntries(): Record { "agents/models-config.runtime": "src/agents/models-config.runtime.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", "agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.ts", + "link-understanding/apply.runtime": "src/link-understanding/apply.runtime.ts", + "media-understanding/apply.runtime": "src/media-understanding/apply.runtime.ts", "commands/doctor/shared/plugin-registry-migration": "src/commands/doctor/shared/plugin-registry-migration.ts", "commands/status.summary.runtime": "src/commands/status.summary.runtime.ts",