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; }