From 5022a6c6f2c44ae9c78bbb282d1f7bb853aaa1f3 Mon Sep 17 00:00:00 2001 From: MOLW <85048713+MatrixAdventurer@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:16:56 +0800 Subject: [PATCH] fix(openclaw): preserve mutation content whitespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep authoritative response indentation and trailing whitespace in stored and corrected records and their cache entries. Preserve trimming for other tools and consume only the mutation formatter's delimiter space. Cover store and both correction paths with native HTTP regressions. 由 LW 授权的 ChatGPT Business 智能体提交。 --- .../__tests__/mutation-whitespace.test.ts | 70 +++++++++++++++++++ plugins/openclaw/openclaw/client.ts | 18 +++-- 2 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 plugins/openclaw/openclaw/__tests__/mutation-whitespace.test.ts diff --git a/plugins/openclaw/openclaw/__tests__/mutation-whitespace.test.ts b/plugins/openclaw/openclaw/__tests__/mutation-whitespace.test.ts new file mode 100644 index 00000000..20b1c26d --- /dev/null +++ b/plugins/openclaw/openclaw/__tests__/mutation-whitespace.test.ts @@ -0,0 +1,70 @@ +import { once } from "node:events"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; +import { MemoriaClient } from "../client.js"; +import { buildApiConfig } from "./helpers.js"; + +describe.each(["store", "correctById", "correctByQuery"] as const)( + "%s preserves response content", + (operation) => { + it.each([ + { label: "Python indentation", content: " return value\n" }, + { label: "tabs and Unicode", content: "\n\t记忆 😀\r\n " }, + { label: "trailing whitespace", content: "end \t" }, + { label: "leading newline", content: "\r\nfirst\nsecond" }, + { label: "plain control", content: "ordinary text" }, + ])("keeps $label in the result and cache", async ({ content }) => { + const requests: Array<{ method: string; url: string; body: unknown }> = []; + // A real loopback HTTP fixture, not a live Memoria database. The response + // deliberately differs from the request to prove we preserve server data. + const record = { memory_id: "result-id", content, memory_type: "semantic" }; + const server = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + requests.push({ + method: request.method!, url: request.url!, body: body ? JSON.parse(body) : null, + }); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify(record)); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const config = buildApiConfig({ + apiUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + }); + const client = new MemoriaClient(config); + const freshClient = new MemoriaClient(config); + try { + const requestContent = "request content"; + const result = operation === "store" + ? await client.storeMemory({ userId: "u", content: requestContent, memoryType: "semantic" }) + : operation === "correctById" + ? await client.correctById({ userId: "u", memoryId: "old-id", newContent: requestContent }) + : await client.correctByQuery({ userId: "u", query: "old", newContent: requestContent }); + expect(requests).toEqual([operation === "store" + ? { method: "POST", url: "/v1/memories", body: { content: requestContent, memory_type: "semantic" } } + : { + method: operation === "correctById" ? "PUT" : "POST", + url: operation === "correctById" ? "/v1/memories/old-id/correct" : "/v1/memories/correct", + body: operation === "correctById" + ? { new_content: requestContent } + : { query: "old", new_content: requestContent }, + }]); + const cached = await client.getMemory({ userId: "u", memoryId: "result-id" }); + expect(requests).toHaveLength(1); // The actual client cache was exercised. + const fetched = await freshClient.getMemory({ userId: "u", memoryId: "result-id" }); + expect(requests[1]).toEqual({ method: "GET", url: "/v1/memories/result-id", body: null }); + expect(fetched?.content).toBe(content); + expect(result).toMatchObject({ memory_id: "result-id", content }); + expect(cached?.content).toBe(fetched?.content); + } finally { + client.close(); + freshClient.close(); + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + } + }); + }, +); diff --git a/plugins/openclaw/openclaw/client.ts b/plugins/openclaw/openclaw/client.ts index a426de93..004fd390 100644 --- a/plugins/openclaw/openclaw/client.ts +++ b/plugins/openclaw/openclaw/client.ts @@ -140,12 +140,12 @@ function normalizeTypeCounts(value: unknown): Record { function extractToolText(result: unknown): string { const record = asRecord(result); if (!record) { - return typeof result === "string" ? result.trim() : ""; + return typeof result === "string" ? result : ""; } const content = record.content; if (!Array.isArray(content)) { - return typeof record.text === "string" ? record.text.trim() : ""; + return typeof record.text === "string" ? record.text : ""; } const parts: string[] = []; @@ -155,7 +155,7 @@ function extractToolText(result: unknown): string { parts.push(block.text); } } - return parts.join("\n").trim(); + return parts.join("\n"); } function parseMemoryTextList(text: string): MemoriaMemoryRecord[] { @@ -221,7 +221,8 @@ function parseStoredMemory(text: string, fallback: { trustTier?: MemoriaTrustTier; sessionId?: string; }): MemoriaMemoryRecord { - const match = text.match(/^Stored memory ([^:]+):\s*([\s\S]*)$/); + // Consume only the formatter's separator, not the memory's own indentation. + const match = text.match(/^Stored memory ([^:]+): ?([\s\S]*)$/); return normalizeMemoryRecord({ memory_id: match?.[1]?.trim() ?? "", content: match?.[2] ?? fallback.content, @@ -232,7 +233,7 @@ function parseStoredMemory(text: string, fallback: { } function parseCorrectedMemory(text: string, fallbackContent: string) { - const match = text.match(/^Corrected memory ([^:]+):\s*([\s\S]*)$/); + const match = text.match(/^Corrected memory ([^:]+): ?([\s\S]*)$/); if (!match) { return null; } @@ -894,7 +895,12 @@ export class MemoriaClient { ): Promise { const session = this.getSession(userId); const result = await session.callTool(name, args); - const text = extractToolText(result); + const rawText = extractToolText(result); + // Mutation text ends in the stored content itself; trimming it corrupts + // both the returned record and the cache. Other tools keep their contract. + const text = name === "memory_store" || name === "memory_correct" + ? rawText + : rawText.trim(); if (text) { return text; }