From cf14b27d81e62baaf7ef08a2abbbfe7b80e2590e Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 4 Jul 2026 16:33:27 -0700 Subject: [PATCH] refactor: consolidate safe json parsing (#99688) --- packages/normalization-core/src/index.ts | 1 + .../src/json-coercion.test.ts | 12 +++++++++++ .../normalization-core/src/json-coercion.ts | 8 +++++++ src/acp/runtime/session-meta.ts | 21 ++++++------------- ...mbedded-agent-message-tool-source-reply.ts | 11 +++------- src/agents/subagent-yield-output.ts | 7 ++----- src/chat/canvas-render.ts | 15 ++----------- 7 files changed, 34 insertions(+), 41 deletions(-) create mode 100644 packages/normalization-core/src/json-coercion.test.ts create mode 100644 packages/normalization-core/src/json-coercion.ts diff --git a/packages/normalization-core/src/index.ts b/packages/normalization-core/src/index.ts index 858ea7b40ade..4445cc6e654c 100644 --- a/packages/normalization-core/src/index.ts +++ b/packages/normalization-core/src/index.ts @@ -2,6 +2,7 @@ export * from "./boolean-coercion.js"; export * from "./error-coercion.js"; +export * from "./json-coercion.js"; export * from "./number-coercion.js"; export * from "./record-coerce.js"; export * from "./string-coerce.js"; diff --git a/packages/normalization-core/src/json-coercion.test.ts b/packages/normalization-core/src/json-coercion.test.ts new file mode 100644 index 000000000000..6a3952061269 --- /dev/null +++ b/packages/normalization-core/src/json-coercion.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { safeParseJson } from "./json-coercion.js"; + +describe("json-coercion", () => { + it.each<[string, unknown]>([ + ['{"ok":true}', { ok: true }], + ["[1]", [1]], + ['"text"', "text"], + ["null", null], + ["{", undefined], + ])("parses %s", (value, expected) => expect(safeParseJson(value)).toEqual(expected)); +}); diff --git a/packages/normalization-core/src/json-coercion.ts b/packages/normalization-core/src/json-coercion.ts new file mode 100644 index 000000000000..e4f8448a944d --- /dev/null +++ b/packages/normalization-core/src/json-coercion.ts @@ -0,0 +1,8 @@ +/** Parses JSON without throwing, returning undefined for invalid input. */ +export function safeParseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +} diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index f28b0c61634d..df1eef0210a8 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -1,5 +1,7 @@ /** SQLite-backed ACP session metadata storage keyed through session-store entries. */ import type { DatabaseSync } from "node:sqlite"; +import { safeParseJson } from "@openclaw/normalization-core"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { Insertable, Selectable } from "kysely"; import { getRuntimeConfig } from "../../config/config.js"; @@ -29,7 +31,6 @@ import { type OpenClawStateDatabaseOptions, runOpenClawStateWriteTransaction, } from "../../state/openclaw-state-db.js"; -import { isRecord } from "../../utils.js"; /** ACP metadata joined with its legacy session-store row and config context. */ export type AcpSessionStoreEntry = { @@ -89,21 +90,11 @@ function getAcpSessionKysely(db: DatabaseSync) { return getNodeSqliteKysely(db); } -function parseOptionalJsonRecord(raw: string | null): Record | undefined { - if (raw == null || raw === "") { - return undefined; - } - try { - const parsed = JSON.parse(raw) as unknown; - return isRecord(parsed) ? parsed : undefined; - } catch { - return undefined; - } -} - function rowToAcpSessionMeta(row: AcpSessionRow): SessionAcpMeta { - const identity = parseOptionalJsonRecord(row.identity_json) as SessionAcpIdentity | undefined; - const runtimeOptions = parseOptionalJsonRecord(row.runtime_options_json) as + const identity = asOptionalRecord(safeParseJson(row.identity_json ?? "")) as + | SessionAcpIdentity + | undefined; + const runtimeOptions = asOptionalRecord(safeParseJson(row.runtime_options_json ?? "")) as | AcpSessionRuntimeOptions | undefined; return { diff --git a/src/agents/embedded-agent-message-tool-source-reply.ts b/src/agents/embedded-agent-message-tool-source-reply.ts index 8fe3b9359dd1..a05a23aeaf41 100644 --- a/src/agents/embedded-agent-message-tool-source-reply.ts +++ b/src/agents/embedded-agent-message-tool-source-reply.ts @@ -1,6 +1,8 @@ /** * Detects message-tool sends that delivered a visible reply to the current source. */ +import { safeParseJson } from "@openclaw/normalization-core"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; import { isMessageToolConversationCreateActionName, @@ -70,14 +72,7 @@ function isBareSentDeliveryStatus(value: unknown): boolean { } function parseJsonRecord(value: string): Record | undefined { - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : undefined; - } catch { - return undefined; - } + return asOptionalRecord(safeParseJson(value)); } function recordHasDeliveredMessageId(record: Record): boolean { diff --git a/src/agents/subagent-yield-output.ts b/src/agents/subagent-yield-output.ts index 6a47f8de424e..771091bee04c 100644 --- a/src/agents/subagent-yield-output.ts +++ b/src/agents/subagent-yield-output.ts @@ -3,6 +3,7 @@ * * Accepts provider-specific tool-call and tool-result shapes used by transcript repair and announce capture. */ +import { safeParseJson } from "@openclaw/normalization-core"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { readTrimmedStringAlias } from "../utils/string-readers.js"; @@ -50,11 +51,7 @@ function parseJsonObject(text: string): Record | undefined { if (!trimmed.startsWith("{")) { return undefined; } - try { - return asOptionalRecord(JSON.parse(trimmed)); - } catch { - return undefined; - } + return asOptionalRecord(safeParseJson(trimmed)); } function readStructuredToolPayload(content: unknown): Record | undefined { diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index 167d2c11b400..1c3fca509510 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -1,4 +1,5 @@ // Renders chat canvas payloads into text and metadata for transcript output. +import { safeParseJson } from "@openclaw/normalization-core"; import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { parseFenceSpans } from "../../packages/markdown-core/src/fences.js"; @@ -19,18 +20,6 @@ type CanvasPreview = { style?: string; }; -function tryParseJsonRecord(value: string | undefined): Record | undefined { - if (typeof value !== "string") { - return undefined; - } - try { - const parsed = JSON.parse(value); - return asOptionalRecord(parsed); - } catch { - return undefined; - } -} - function getRecordStringField( record: Record | undefined, key: string, @@ -184,7 +173,7 @@ export function extractCanvasFromText( outputText: string | undefined, _toolName?: string, ): CanvasPreview | undefined { - const parsed = tryParseJsonRecord(outputText); + const parsed = outputText ? asOptionalRecord(safeParseJson(outputText)) : undefined; return coerceCanvasPreview(parsed); }