From ed83d79a05eab60b091672de9d198c21fb697e6f Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:52:36 -0500 Subject: [PATCH] fix: tighten reply payload typing and safe text coercion --- src/agents/pi-embedded-helpers/errors.ts | 23 +++++++++++++++- ...pi-embedded-subscribe.handlers.messages.ts | 26 +++++++++++++++++-- src/plugin-sdk/approval-renderers.test.ts | 5 +++- src/shared/chat-content.ts | 26 +++++++++++++++++-- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 67cd9b40438..68a843469cc 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -545,7 +545,28 @@ export function classifyFailoverReasonFromHttpStatus( } function coerceText(value: unknown): string { - return typeof value === "string" ? value : value == null ? "" : String(value); + if (typeof value === "string") { + return value; + } + if (value == null) { + return ""; + } + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" || + typeof value === "symbol" + ) { + return String(value); + } + if (typeof value === "object") { + try { + return JSON.stringify(value) ?? ""; + } catch { + return ""; + } + } + return ""; } function stripFinalTagsFromText(text: unknown): string { diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index d3a35013efc..3f62ae31e7e 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -38,8 +38,30 @@ const stripTrailingDirective = (text: string): string => { return text.slice(0, openIndex); }; -const coerceText = (value: unknown): string => - typeof value === "string" ? value : value == null ? "" : String(value); +const coerceText = (value: unknown): string => { + if (typeof value === "string") { + return value; + } + if (value == null) { + return ""; + } + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" || + typeof value === "symbol" + ) { + return String(value); + } + if (typeof value === "object") { + try { + return JSON.stringify(value) ?? ""; + } catch { + return ""; + } + } + return ""; +}; function isTranscriptOnlyOpenClawAssistantMessage(message: AgentMessage | undefined): boolean { if (!message || message.role !== "assistant") { diff --git a/src/plugin-sdk/approval-renderers.test.ts b/src/plugin-sdk/approval-renderers.test.ts index fe2390aa17e..7de464abd5b 100644 --- a/src/plugin-sdk/approval-renderers.test.ts +++ b/src/plugin-sdk/approval-renderers.test.ts @@ -145,7 +145,10 @@ describe("plugin-sdk/approval-renderers", () => { }, }, ])("$name", ({ payload, textExpected, interactiveExpected, channelDataExpected }) => { - textExpected(payload.text); + expect(payload.text).toBeDefined(); + if (payload.text !== undefined) { + textExpected(payload.text); + } if (interactiveExpected) { expect(payload.interactive).toEqual(interactiveExpected); } diff --git a/src/shared/chat-content.ts b/src/shared/chat-content.ts index b87cf53e136..30e32c8f34e 100644 --- a/src/shared/chat-content.ts +++ b/src/shared/chat-content.ts @@ -8,8 +8,30 @@ export function extractTextFromChatContent( ): string | null { const normalizeText = opts?.normalizeText ?? ((text: string) => text.replace(/\s+/g, " ").trim()); const joinWith = opts?.joinWith ?? " "; - const coerceText = (value: unknown): string => - typeof value === "string" ? value : value == null ? "" : String(value); + const coerceText = (value: unknown): string => { + if (typeof value === "string") { + return value; + } + if (value == null) { + return ""; + } + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" || + typeof value === "symbol" + ) { + return String(value); + } + if (typeof value === "object") { + try { + return JSON.stringify(value) ?? ""; + } catch { + return ""; + } + } + return ""; + }; const sanitize = (text: unknown): string => { const raw = coerceText(text); const sanitized = opts?.sanitizeText ? opts.sanitizeText(raw) : raw;