fix: keep history-backed chat images visible

This commit is contained in:
Peter Steinberger
2026-04-18 20:27:38 +01:00
parent 98316cfbbd
commit 6d40de45c7
8 changed files with 260 additions and 43 deletions

View File

@@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai
- Slack/threads: keep file-only root messages as starter context so first thread replies can still hydrate starter media. (#68594) Thanks @martingarramon.
- Google/Antigravity: resolve forward-compatible Gemini 3.1 Pro custom-tools and Flash variants from the bundled Google plugin templates, so `google-antigravity/gemini-3.1-pro-preview-customtools` no longer falls through to an unknown-model error. Fixes #35512.
- Active Memory: raise the blocking recall timeout ceiling to 120 seconds and reject larger config values during plugin schema validation. Fixes #68410. (#68480) Thanks @Bartok9.
- Control UI/chat: keep history-backed user image uploads visible after chat reload while filtering blocked or non-image transcript media paths. (#68415) Thanks @mraleko.
## 2026.4.15

View File

@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const getBundledChannelPluginMock = vi.hoisted(() => vi.fn());
const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn());
vi.mock("./bundled.js", () => ({
getBundledChannelPlugin: getBundledChannelPluginMock,
}));
vi.mock("./registry.js", () => ({
getLoadedChannelPlugin: getLoadedChannelPluginMock,
}));
import {
resolveSingleAccountKeysToMove,
shouldMoveSingleAccountChannelKey,
} from "./setup-promotion-helpers.js";
describe("setup promotion helpers", () => {
beforeEach(() => {
getBundledChannelPluginMock.mockReset();
getLoadedChannelPluginMock.mockReset();
});
it("keeps static named-account migration keys cheap", () => {
const keys = resolveSingleAccountKeysToMove({
channelKey: "whatsapp",
channel: {
accounts: {
work: { enabled: true },
},
dmPolicy: "allowlist",
allowFrom: ["+15551234567"],
groupPolicy: "allowlist",
groupAllowFrom: ["120363000000000000@g.us"],
},
});
expect(keys).toEqual(["dmPolicy", "allowFrom", "groupPolicy", "groupAllowFrom"]);
expect(getLoadedChannelPluginMock).toHaveBeenCalledTimes(1);
expect(getLoadedChannelPluginMock).toHaveBeenCalledWith("whatsapp");
expect(getBundledChannelPluginMock).not.toHaveBeenCalled();
});
it("loads bundled setup only for non-static migration keys", () => {
getBundledChannelPluginMock.mockReturnValue({
setup: {
singleAccountKeysToMove: ["customAuth"],
},
});
expect(
shouldMoveSingleAccountChannelKey({
channelKey: "demo",
key: "customAuth",
}),
).toBe(true);
expect(getBundledChannelPluginMock).toHaveBeenCalledWith("demo");
});
it("honors loaded plugin named-account filters without bundled fallback", () => {
getLoadedChannelPluginMock.mockReturnValue({
setup: {
namedAccountPromotionKeys: ["token"],
},
});
const keys = resolveSingleAccountKeysToMove({
channelKey: "demo",
channel: {
accounts: {
work: { enabled: true },
},
token: "secret",
dmPolicy: "allowlist",
},
});
expect(keys).toEqual(["token"]);
expect(getBundledChannelPluginMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,7 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { getBundledChannelPlugin } from "./bundled.js";
import { getChannelPlugin } from "./registry.js";
import { getLoadedChannelPlugin } from "./registry.js";
type ChannelSectionBase = {
defaultAccount?: string;
@@ -59,8 +59,13 @@ type ChannelSetupPromotionSurface = {
}) => string | undefined;
};
function getChannelSetupPromotionSurface(channelKey: string): ChannelSetupPromotionSurface | null {
const setup = getChannelPlugin(channelKey)?.setup ?? getBundledChannelPlugin(channelKey)?.setup;
function getChannelSetupPromotionSurface(
channelKey: string,
opts?: { loadBundledFallback?: boolean },
): ChannelSetupPromotionSurface | null {
const setup =
getLoadedChannelPlugin(channelKey)?.setup ??
(opts?.loadBundledFallback ? getBundledChannelPlugin(channelKey)?.setup : undefined);
if (!setup || typeof setup !== "object") {
return null;
}
@@ -81,7 +86,9 @@ export function shouldMoveSingleAccountChannelKey(params: {
if (isStaticSingleAccountPromotionKey(params.channelKey, params.key)) {
return true;
}
const contractKeys = getChannelSetupPromotionSurface(params.channelKey)?.singleAccountKeysToMove;
const contractKeys = getChannelSetupPromotionSurface(params.channelKey, {
loadBundledFallback: true,
})?.singleAccountKeysToMove;
if (contractKeys?.includes(params.key)) {
return true;
}
@@ -104,7 +111,9 @@ export function resolveSingleAccountKeysToMove(params: {
let setupSurface: ChannelSetupPromotionSurface | null | undefined;
const resolveSetupSurface = () => {
setupSurface ??= getChannelSetupPromotionSurface(params.channelKey);
setupSurface ??= getChannelSetupPromotionSurface(params.channelKey, {
loadBundledFallback: true,
});
return setupSurface;
};
@@ -119,7 +128,8 @@ export function resolveSingleAccountKeysToMove(params: {
}
const namedAccountPromotionKeys =
resolveSetupSurface()?.namedAccountPromotionKeys ??
setupSurface?.namedAccountPromotionKeys ??
getChannelSetupPromotionSurface(params.channelKey)?.namedAccountPromotionKeys ??
BUNDLED_NAMED_ACCOUNT_PROMOTION_FALLBACKS[params.channelKey];
if (!namedAccountPromotionKeys) {
return keysToMove;
@@ -139,7 +149,9 @@ export function resolveSingleAccountPromotionTarget(params: {
);
return matchedAccountId ?? normalizedTargetAccountId;
};
const surface = getChannelSetupPromotionSurface(params.channelKey);
const surface = getChannelSetupPromotionSurface(params.channelKey, {
loadBundledFallback: true,
});
const resolved = surface?.resolveSingleAccountPromotionTarget?.({
channel: params.channel,
});

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
import {
applyAgentDefaults,
@@ -21,6 +21,12 @@ vi.mock("./provider-policy.js", () => ({
describe("config defaults", () => {
beforeEach(() => {
mocks.applyProviderConfigDefaultsForConfig.mockReset();
vi.stubEnv("ANTHROPIC_API_KEY", "");
vi.stubEnv("ANTHROPIC_OAUTH_TOKEN", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("skips provider defaults when agent defaults are absent", () => {
@@ -38,12 +44,28 @@ describe("config defaults", () => {
expect(mocks.applyProviderConfigDefaultsForConfig).not.toHaveBeenCalled();
});
it("uses anthropic provider defaults when agent defaults exist", () => {
it("skips provider defaults when agent defaults have no Anthropic auth signal", () => {
const cfg = {
agents: {
defaults: {},
},
};
expect(applyContextPruningDefaults(cfg as never)).toBe(cfg);
expect(mocks.applyProviderConfigDefaultsForConfig).not.toHaveBeenCalled();
});
it("uses anthropic provider defaults when agent defaults and auth signal exist", () => {
const cfg = {
auth: {
profiles: {
anthropic: { provider: "anthropic", mode: "api_key" },
},
},
agents: {
defaults: {},
},
};
const nextCfg = {
agents: {
defaults: {

View File

@@ -338,10 +338,39 @@ export function applyLoggingDefaults(cfg: OpenClawConfig): OpenClawConfig {
};
}
function hasAnthropicDefaultSignal(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_OAUTH_TOKEN?.trim()) {
return true;
}
const profiles = cfg.auth?.profiles;
if (profiles) {
for (const profile of Object.values(profiles)) {
const provider = normalizeProviderId(profile?.provider);
if (provider === "anthropic" || provider === "claude-cli") {
return true;
}
}
}
const order = cfg.auth?.order;
if (!order) {
return false;
}
return Object.keys(order).some((provider) => {
const normalizedProvider = normalizeProviderId(provider);
if (normalizedProvider !== "anthropic" && normalizedProvider !== "claude-cli") {
return false;
}
return (order as Record<string, unknown>)[provider] !== undefined;
});
}
export function applyContextPruningDefaults(cfg: OpenClawConfig): OpenClawConfig {
if (!cfg.agents?.defaults) {
return cfg;
}
if (!hasAnthropicDefaultSignal(cfg, process.env)) {
return cfg;
}
return (
applyProviderConfigDefaultsForConfig({
provider: "anthropic",

View File

@@ -26,6 +26,11 @@ export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise
OPENCLAW_MPM_CATALOG_PATHS: undefined,
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: undefined,
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: undefined,
OPENCLAW_LOAD_SHELL_ENV: undefined,
OPENCLAW_DEFER_SHELL_ENV_FALLBACK: undefined,
OPENCLAW_SHELL_ENV_TIMEOUT_MS: undefined,
ANTHROPIC_API_KEY: undefined,
ANTHROPIC_OAUTH_TOKEN: undefined,
},
});
} finally {

View File

@@ -52,6 +52,16 @@ type ImageBlock = {
alt?: string;
};
type ImageRenderOptions = {
localMediaPreviewRoots?: readonly string[];
basePath?: string;
authToken?: string | null;
};
type RenderableImageBlock = ImageBlock & {
displayUrl: string;
};
function appendImageBlock(images: ImageBlock[], block: ImageBlock) {
if (!images.some((entry) => entry.url === block.url && entry.alt === block.alt)) {
images.push(block);
@@ -130,6 +140,15 @@ function extractImages(message: unknown): ImageBlock[] {
appendImageBlock(images, { url: imageUrl.url });
}
} else if (b.type === "input_image") {
const imageUrl = b.image_url;
if (typeof imageUrl === "string") {
appendImageBlock(images, { url: imageUrl });
} else if (imageUrl && typeof imageUrl === "object") {
const url = (imageUrl as Record<string, unknown>).url;
if (typeof url === "string") {
appendImageBlock(images, { url });
}
}
const source = b.source as Record<string, unknown> | undefined;
if (typeof source?.url === "string") {
appendImageBlock(images, { url: source.url });
@@ -651,14 +670,25 @@ function isAvatarUrl(value: string): boolean {
);
}
function renderMessageImages(
function resolveRenderableMessageImages(
images: ImageBlock[],
opts?: {
localMediaPreviewRoots?: readonly string[];
basePath?: string;
authToken?: string | null;
},
) {
opts?: ImageRenderOptions,
): RenderableImageBlock[] {
return images.flatMap((img) => {
const isLocalImage = isLocalAssistantAttachmentSource(img.url);
const canProxyLocalImage =
isLocalImage && isLocalAttachmentPreviewAllowed(img.url, opts?.localMediaPreviewRoots ?? []);
if (isLocalImage && !canProxyLocalImage) {
return [];
}
const displayUrl = canProxyLocalImage
? buildAssistantAttachmentUrl(img.url, opts?.basePath, opts?.authToken)
: img.url;
return [{ ...img, displayUrl }];
});
}
function renderMessageImages(images: RenderableImageBlock[]) {
if (images.length === 0) {
return nothing;
}
@@ -669,26 +699,16 @@ function renderMessageImages(
return html`
<div class="chat-message-images">
${images.map((img) => {
const isLocalImage = isLocalAssistantAttachmentSource(img.url);
const canProxyLocalImage =
isLocalImage &&
isLocalAttachmentPreviewAllowed(img.url, opts?.localMediaPreviewRoots ?? []);
if (isLocalImage && !canProxyLocalImage) {
return nothing;
}
const imageUrl = canProxyLocalImage
? buildAssistantAttachmentUrl(img.url, opts?.basePath, opts?.authToken)
: img.url;
return html`
${images.map(
(img) => html`
<img
src=${imageUrl}
src=${img.displayUrl}
alt=${img.alt ?? "Attached image"}
class="chat-message-image"
@click=${() => openImage(imageUrl)}
@click=${() => openImage(img.displayUrl)}
/>
`;
})}
`,
)}
</div>
`;
}
@@ -1155,7 +1175,12 @@ function renderGroupedMessage(
const toolCards = (opts.showToolCalls ?? true) ? extractToolCards(message, messageKey) : [];
const hasToolCards = toolCards.length > 0;
const images = extractImages(message);
const imageRenderOptions = {
localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [],
basePath: opts.basePath,
authToken: opts.assistantAttachmentAuthToken,
};
const images = resolveRenderableMessageImages(extractImages(message), imageRenderOptions);
const hasImages = images.length > 0;
const normalizedMessage = normalizeMessage(message);
@@ -1256,11 +1281,7 @@ function renderGroupedMessage(
${toolMessageExpanded
? html`
<div class="chat-tool-msg-body">
${renderMessageImages(images, {
localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [],
basePath: opts.basePath,
authToken: opts.assistantAttachmentAuthToken,
})}
${renderMessageImages(images)}
${renderAssistantAttachments(
assistantAttachments,
opts.localMediaPreviewRoots ?? [],
@@ -1316,11 +1337,7 @@ function renderGroupedMessage(
</div>
`
: html`
${renderMessageImages(images, {
localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [],
basePath: opts.basePath,
authToken: opts.assistantAttachmentAuthToken,
})}
${renderMessageImages(images)}
${renderAssistantAttachments(
assistantAttachments,
opts.localMediaPreviewRoots ?? [],

View File

@@ -974,6 +974,37 @@ describe("chat view", () => {
);
});
it("keeps plural user transcript images visible after history reload", () => {
const container = document.createElement("div");
renderGroupedMessage(
container,
{
id: "user-history-images",
role: "user",
content: "",
MediaPaths: ["/tmp/openclaw/first.png", "/tmp/openclaw/second.jpg"],
MediaTypes: ["image/png", "application/octet-stream"],
timestamp: Date.now(),
},
"user",
{
showToolCalls: false,
basePath: "/openclaw",
assistantAttachmentAuthToken: "session-token",
localMediaPreviewRoots: ["/tmp/openclaw"],
},
);
const imageSources = [
...container.querySelectorAll<HTMLImageElement>(".chat-message-image"),
].map((image) => image.getAttribute("src"));
expect(imageSources).toEqual([
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ffirst.png&token=session-token",
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fsecond.jpg&token=session-token",
]);
});
it("does not render blocked local transcript image paths", () => {
const container = document.createElement("div");
@@ -997,6 +1028,7 @@ describe("chat view", () => {
);
expect(container.querySelector(".chat-message-image")).toBeNull();
expect(container.querySelector(".chat-bubble")).toBeNull();
});
it("skips non-image transcript media paths after history reload", () => {
@@ -1024,6 +1056,23 @@ describe("chat view", () => {
expect(container.querySelector(".chat-message-image")).toBeNull();
});
it("renders legacy input_image image_url blocks", () => {
const container = document.createElement("div");
renderAssistantMessage(
container,
{
role: "assistant",
content: [{ type: "input_image", image_url: "data:image/png;base64,cG5n" }],
timestamp: Date.now(),
},
{ showToolCalls: false },
);
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
expect(image?.getAttribute("src")).toBe("data:image/png;base64,cG5n");
});
it("opens only safe assistant image URLs in a hardened new tab", () => {
const container = document.createElement("div");
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);