From 119a353641c62948a57b44dfa161cacc0397e020 Mon Sep 17 00:00:00 2001 From: 0xVox Date: Tue, 2 Jun 2026 22:51:48 -0700 Subject: [PATCH] fix(openclaw): saveMemories posts a PersonalAddRequest envelope with top-level user_id (closes #237) POST /api/v1/memories returned HTTP 422 ("Field required: user_id") because saveMemories sent flat, per-message bodies with no top-level user_id. The v1 PersonalAddRequest DTO is an envelope: a required top-level user_id plus a messages[] list of MessageItem, and each MessageItem requires an integer unix-milliseconds timestamp. Rewrite saveMemories to send a single batched envelope { user_id, session_id?, messages: [...] } and align each item with the MessageItem DTO: - timestamp: required unix-ms integer (the old create_time ISO string was ignored by the converter, leaving timestamp missing -> 422). - sender_id: set to user_id only for role=user; omitted for role=assistant so the backend derives a distinct id (personal-scene converter rejects assistant turns whose sender_id equals user_id). - session_id: plumbed through from the OpenClaw sessionKey (engine.js) for conversation isolation; optional in the DTO. Verified live against a running EverCore on :1995: the old flat body returns 422 "Field required: user_id"; the new envelope returns 200 {"status":"accumulated","message":"Messages accepted"}. Adds test/save-memories.test.js (node --test, offline) asserting the POST body carries a top-level user_id, a session_id, a messages array with integer timestamps and no legacy create_time, and the correct per-role sender_id semantics; plus that an empty batch sends nothing. Scope: api.js + the engine.js sessionId plumbing + its regression test. Does not bundle the separate api.pluginConfig forwarding fix (#150/#139), which ships as its own PR. Prior art adopted: PR #189 (kevinwon) converted saveMemories to the same top-level-user_id batch envelope; PR #128 (Void Freud) set user_id at the top level in the same saveMemories path. Closes #237. Co-authored-by: kevinwon <2725361+ww-k@users.noreply.github.com> Co-authored-by: Void Freud <246163318+voidfreud@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) --- .../examples/openclaw-plugin/src/api.js | 46 ++++++---- .../examples/openclaw-plugin/src/engine.js | 3 +- .../test/save-memories.test.js | 85 +++++++++++++++++++ 3 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 methods/EverCore/examples/openclaw-plugin/test/save-memories.test.js diff --git a/methods/EverCore/examples/openclaw-plugin/src/api.js b/methods/EverCore/examples/openclaw-plugin/src/api.js index eb7ee9fc..40f4ac55 100644 --- a/methods/EverCore/examples/openclaw-plugin/src/api.js +++ b/methods/EverCore/examples/openclaw-plugin/src/api.js @@ -37,34 +37,46 @@ export async function searchMemories(cfg, params, log = noop) { }; } -export async function saveMemories(cfg, { userId, groupId, messages = [], flush = false, idSeed = "" }) { +export async function saveMemories(cfg, { userId, sessionId, messages = [], idSeed = "" }) { if (!messages.length) return; const stamp = Date.now(); - const payloads = messages.map((msg, i) => { + // PersonalAddRequest (POST /api/v1/memories) is an ENVELOPE: a top-level + // user_id plus a `messages` array of MessageItem. Posting flat per-message + // bodies without a top-level user_id triggers HTTP 422 + // ("Field required: user_id") — see issue #237. + const items = messages.map((msg, i) => { const { role = "user", content = "" } = msg; - // Always use userId as sender so the backend stores a consistent user_id - // for both user and assistant messages. The `role` field distinguishes who spoke. - const sender = userId; const senderName = role === "assistant" ? "assistant" : userId; - const isLast = i === messages.length - 1; - return { + const item = { message_id: messageId(idSeed, role, content), - create_time: new Date(stamp + i).toISOString(), + // MessageItem.timestamp is a REQUIRED unix-milliseconds integer. + // The previous create_time(ISO) field was ignored by the converter and + // left timestamp missing, failing validation. + timestamp: stamp + i, role, - sender, sender_name: senderName, content, - group_id: groupId, - group_name: groupId, - scene: "assistant", - raw_data_type: "AgentConversation", - ...(flush && isLast && { flush: true }), }; + + // Personal-scene sender_id rules (request_converter.py): + // role=user -> sender_id must equal user_id (or be omitted) + // role=assistant -> sender_id must NOT equal user_id (backend generates one) + // So only set sender_id for user turns; let the backend derive it for + // assistant turns to avoid a sender_id conflict. + if (role === "user") { + item.sender_id = userId; + } + + return item; }); - for (const payload of payloads) { - await request(cfg, "POST", "/api/v1/memories", payload); - } + const body = { + user_id: userId, + messages: items, + ...(sessionId ? { session_id: sessionId } : {}), + }; + + await request(cfg, "POST", "/api/v1/memories", body); } diff --git a/methods/EverCore/examples/openclaw-plugin/src/engine.js b/methods/EverCore/examples/openclaw-plugin/src/engine.js index 00a99f14..423b3526 100644 --- a/methods/EverCore/examples/openclaw-plugin/src/engine.js +++ b/methods/EverCore/examples/openclaw-plugin/src/engine.js @@ -140,7 +140,8 @@ export function createContextEngine(pluginMeta, pluginConfig, logger) { await saveMemories(cfg, { userId: cfg.userId, - groupId: cfg.groupId, + // sessionKey scopes the personal-add envelope to this conversation. + sessionId: sessionKey, messages: converted, idSeed: `${sessionKey}:${state.turnCount}`, }); diff --git a/methods/EverCore/examples/openclaw-plugin/test/save-memories.test.js b/methods/EverCore/examples/openclaw-plugin/test/save-memories.test.js new file mode 100644 index 00000000..ba724a27 --- /dev/null +++ b/methods/EverCore/examples/openclaw-plugin/test/save-memories.test.js @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { saveMemories } from "../src/api.js"; + +// Regression for #237: POST /api/v1/memories was 422-ing with +// "Field required: user_id" because saveMemories sent flat per-message bodies. +// PersonalAddRequest is an envelope: top-level user_id + a messages[] array, +// each MessageItem carrying a required unix-milliseconds integer timestamp. +test("saveMemories posts a PersonalAddRequest envelope with top-level user_id (fix #237)", async () => { + const cfg = { serverUrl: "http://localhost:1995" }; + const captured = []; + + const realFetch = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + captured.push({ url: String(url), opts }); + return { + ok: true, + status: 200, + async json() { + return { status: "ok" }; + }, + async text() { + return ""; + }, + }; + }; + + try { + await saveMemories(cfg, { + userId: "alice", + sessionId: "sess-1", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + ], + idSeed: "k:1", + }); + } finally { + globalThis.fetch = realFetch; + } + + // A single batched POST to /api/v1/memories (not one request per message). + assert.equal(captured.length, 1, "expected exactly one batched POST"); + assert.equal(captured[0].opts.method, "POST"); + assert.match(captured[0].url, /\/api\/v1\/memories$/); + + const body = JSON.parse(captured[0].opts.body); + + // The 422 fix: top-level user_id must be present. + assert.equal(body.user_id, "alice", "body must carry a top-level user_id"); + assert.equal(body.session_id, "sess-1"); + assert.ok(Array.isArray(body.messages), "body must carry a messages array"); + assert.equal(body.messages.length, 2); + + // MessageItem.timestamp is a REQUIRED unix-milliseconds integer. + for (const m of body.messages) { + assert.equal(typeof m.timestamp, "number", "each message needs a unix-ms timestamp"); + assert.equal(Number.isInteger(m.timestamp), true); + assert.equal("create_time" in m, false, "legacy create_time field must be gone"); + } + + // Personal-scene sender_id rules: user turn carries sender_id=user_id, + // assistant turn omits sender_id so the backend generates a distinct one. + const userMsg = body.messages.find((m) => m.role === "user"); + const asstMsg = body.messages.find((m) => m.role === "assistant"); + assert.equal(userMsg.sender_id, "alice"); + assert.equal("sender_id" in asstMsg, false, "assistant turn must not pin sender_id to user_id"); +}); + +test("saveMemories sends nothing when there are no messages", async () => { + const cfg = { serverUrl: "http://localhost:1995" }; + let called = false; + const realFetch = globalThis.fetch; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, async json() { return {}; }, async text() { return ""; } }; + }; + try { + await saveMemories(cfg, { userId: "alice", messages: [] }); + } finally { + globalThis.fetch = realFetch; + } + assert.equal(called, false, "no POST should be sent for an empty message batch"); +});