fix: keep link understanding from dropping replies

This commit is contained in:
Peter Steinberger
2026-04-27 13:45:00 +01:00
parent fa1f670716
commit 1fbe83d09f
6 changed files with 106 additions and 23 deletions

View File

@@ -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 <id|name|ip>` 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.

View File

@@ -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

View File

@@ -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"),
);
});
});

View File

@@ -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(

View File

@@ -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",

View File

@@ -214,6 +214,8 @@ function buildCoreDistEntries(): Record<string, string> {
"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",