fix(ui): stop chat normalizers throwing on null/undefined message entries (#111494)

normalizeMessage / isToolResultMessage / isStandaloneToolMessageForDisplay
cast their unknown input to a record and then read `typeof m.role`. That
still evaluates `m.role`, so an undefined entry (e.g. groupMessages calling
normalizeMessage(item.message) with an absent message on a pending or
malformed transcript row) threw "Cannot read properties of undefined
(reading 'role')" inside the gateway event handler — a caught but recurring
error storm on the live team instance. Coerce non-object input to {} at the
source so every downstream `typeof m.<field>` check works and role degrades
to "unknown". The existing safeNormalizeMessage try/catch wrapper existed
precisely for this hazard; the guard now lives in normalizeMessage itself.
This commit is contained in:
Peter Steinberger
2026-07-19 10:54:55 -07:00
committed by GitHub
parent 50ce5dedea
commit e6d7aee41d
2 changed files with 43 additions and 4 deletions
+30 -1
View File
@@ -1,12 +1,41 @@
// @vitest-environment node
// Control UI tests cover message normalizer behavior.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { normalizeMessage } from "./message-normalizer.ts";
import {
isStandaloneToolMessageForDisplay,
isToolResultMessage,
normalizeMessage,
} from "./message-normalizer.ts";
const SENDER_METADATA_BLOCK =
'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui","id":"openclaw-control-ui"}\n```';
describe("message-normalizer", () => {
// Regression: gateway/transcript events can carry a null/undefined or
// non-object entry (e.g. a transcript row without a `message`). `typeof
// m.role` still reads `.role` off the object, so an undefined entry threw
// "Cannot read properties of undefined (reading 'role')" inside the gateway
// event handler. These entry points must degrade to a safe default instead.
describe("malformed input never throws", () => {
it.each([undefined, null, "raw string", 42, true])(
"normalizeMessage(%o) yields role 'unknown' without throwing",
(input) => {
expect(() => normalizeMessage(input)).not.toThrow();
expect(normalizeMessage(input).role).toBe("unknown");
},
);
it.each([undefined, null, "raw string", 42, true])(
"tool-message predicates return false for %o without throwing",
(input) => {
expect(() => isToolResultMessage(input)).not.toThrow();
expect(() => isStandaloneToolMessageForDisplay(input)).not.toThrow();
expect(isToolResultMessage(input)).toBe(false);
expect(isStandaloneToolMessageForDisplay(input)).toBe(false);
},
);
});
describe("normalizeMessage", () => {
beforeEach(() => {
vi.useFakeTimers();
+13 -3
View File
@@ -16,6 +16,16 @@ import { getMediaFileExtension } from "../media-file-extension.ts";
import type { NormalizedMessage, MessageContentItem } from "./chat-types.ts";
import { formatSenderLabel, normalizeSenderIdentity } from "./sender-label.ts";
// These normalizers take `unknown` gateway/transcript data. A malformed or
// absent entry can arrive as null/undefined (e.g. a transcript row without a
// `message`), and `typeof m.role` still throws "reading 'role'" when `m` itself
// is undefined — the typeof only guards the property, not the object. Coercing
// a non-object to `{}` keeps every downstream `typeof m.<field>` check working
// and yields role "unknown" instead of crashing the gateway event handler.
function asMessageRecord(message: unknown): Record<string, unknown> {
return message && typeof message === "object" ? (message as Record<string, unknown>) : {};
}
export function normalizeRoleForGrouping(role: string): string {
const lower = role.toLowerCase();
if (lower === "user") {
@@ -39,13 +49,13 @@ export function normalizeRoleForGrouping(role: string): string {
}
export function isToolResultMessage(message: unknown): boolean {
const m = message as Record<string, unknown>;
const m = asMessageRecord(message);
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
return role === "toolresult" || role === "tool_result";
}
export function isStandaloneToolMessageForDisplay(message: unknown): boolean {
const m = message as Record<string, unknown>;
const m = asMessageRecord(message);
const role = typeof m.role === "string" ? normalizeRoleForGrouping(m.role) : "unknown";
return (
role === "tool" ||
@@ -363,7 +373,7 @@ function expandTextContent(text: string): {
* Normalize a raw message object into a consistent structure.
*/
export function normalizeMessage(message: unknown): NormalizedMessage {
const m = message as Record<string, unknown>;
const m = asMessageRecord(message);
let role = typeof m.role === "string" ? m.role : "unknown";
// Detect tool messages by common gateway shapes.