Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions plugins/openclaw/openclaw/__tests__/mutation-whitespace.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
});
}
});
},
);
18 changes: 12 additions & 6 deletions plugins/openclaw/openclaw/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,12 @@ function normalizeTypeCounts(value: unknown): Record<string, number> {
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[] = [];
Expand All @@ -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[] {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -894,7 +895,12 @@ export class MemoriaClient {
): Promise<string> {
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;
}
Expand Down
Loading