Skip to content

Commit 68e4d89

Browse files
authored
afterTurn: store messages with actual roles and skip heartbeat messages (#1340)
1 parent a18d4b9 commit 68e4d89

File tree

3 files changed

+224
-21
lines changed

3 files changed

+224
-21
lines changed

examples/openclaw-plugin/context-engine.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
compileSessionPatterns,
66
getCaptureDecision,
77
extractNewTurnTexts,
8+
extractSingleMessageText,
89
shouldBypassSession,
910
} from "./text-utils.js";
1011
import {
@@ -786,6 +787,10 @@ export function createMemoryOpenVikingContextEngine(params: {
786787
return;
787788
}
788789

790+
if (afterTurnParams.isHeartbeat) {
791+
return;
792+
}
793+
789794
try {
790795
const sessionKey =
791796
(typeof afterTurnParams.sessionKey === "string" && afterTurnParams.sessionKey.trim()) ||
@@ -865,19 +870,38 @@ export function createMemoryOpenVikingContextEngine(params: {
865870
return;
866871
}
867872
const client = await getClient();
868-
const turnText = newTexts.join("\n");
869-
const sanitized = turnText.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ").replace(/\s+/g, " ").trim();
870873
const createdAt = pickLatestCreatedAt(turnMessages);
871874

872-
if (sanitized) {
873-
await client.addSessionMessage(OVSessionId, "user", sanitized, agentId, createdAt);
874-
} else {
875-
diag("afterTurn_skip", OVSessionId, {
876-
reason: "sanitized_empty",
877-
});
875+
// Group by OV role (user|assistant), merge adjacent same-role
876+
const HEARTBEAT_RE = /\bHEARTBEAT(?:\.md|_OK)\b/;
877+
const groups: Array<{ role: "user" | "assistant"; texts: string[] }> = [];
878+
for (const msg of turnMessages) {
879+
const text = extractSingleMessageText(msg);
880+
if (!text) continue;
881+
if (HEARTBEAT_RE.test(text)) continue;
882+
const role = (msg as Record<string, unknown>).role as string;
883+
const ovRole: "user" | "assistant" = role === "assistant" ? "assistant" : "user";
884+
const content = ovRole === "user"
885+
? text.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ").replace(/\s+/g, " ").trim()
886+
: text;
887+
if (!content) continue;
888+
const last = groups[groups.length - 1];
889+
if (last && last.role === ovRole) {
890+
last.texts.push(content);
891+
} else {
892+
groups.push({ role: ovRole, texts: [content] });
893+
}
894+
}
895+
896+
if (groups.length === 0) {
897+
diag("afterTurn_skip", OVSessionId, { reason: "sanitized_empty" });
878898
return;
879899
}
880900

901+
for (const group of groups) {
902+
await client.addSessionMessage(OVSessionId, group.role, group.texts.join("\n"), agentId, createdAt);
903+
}
904+
881905
const session = await client.getSession(OVSessionId, agentId);
882906
const pendingTokens = session.pending_tokens ?? 0;
883907

@@ -891,8 +915,9 @@ export function createMemoryOpenVikingContextEngine(params: {
891915
}
892916

893917
const commitResult = await client.commitSession(OVSessionId, { wait: false, agentId });
918+
const allTexts = groups.flatMap((g) => g.texts).join("\n");
894919
const commitExtra = cfg.logFindRequests
895-
? ` ${toJsonLog({ captured: [trimForLog(turnText, 260)] })}`
920+
? ` ${toJsonLog({ captured: [trimForLog(allTexts, 260)] })}`
896921
: "";
897922
logger.info(
898923
`openviking: committed session=${OVSessionId}, ` +

examples/openclaw-plugin/tests/ut/context-engine-afterTurn.test.ts

Lines changed: 157 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ describe("context-engine afterTurn()", () => {
182182
);
183183
});
184184

185-
it("stores new messages via addSessionMessage", async () => {
185+
it("stores new messages via addSessionMessage with proper roles", async () => {
186186
const { engine, client } = makeEngine();
187187

188188
const messages = [
@@ -198,10 +198,13 @@ describe("context-engine afterTurn()", () => {
198198
prePromptMessageCount: 1,
199199
});
200200

201-
expect(client.addSessionMessage).toHaveBeenCalledTimes(1);
202-
const storedContent = client.addSessionMessage.mock.calls[0][2] as string;
203-
expect(storedContent).toContain("hello world");
204-
expect(storedContent).toContain("hi there");
201+
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
202+
// First call: user message
203+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("user");
204+
expect(client.addSessionMessage.mock.calls[0][2]).toContain("hello world");
205+
// Second call: assistant message
206+
expect(client.addSessionMessage.mock.calls[1][1]).toBe("assistant");
207+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("hi there");
205208
});
206209

207210
it("passes the latest non-system message timestamp to addSessionMessage as ISO string", async () => {
@@ -220,12 +223,14 @@ describe("context-engine afterTurn()", () => {
220223
prePromptMessageCount: 1,
221224
});
222225

223-
expect(client.addSessionMessage).toHaveBeenCalledTimes(1);
224-
const createdAt = client.addSessionMessage.mock.calls[0][4] as string;
226+
// user + assistant + toolResult(→user) = 3 calls (toolResult merges with no adjacent user)
227+
expect(client.addSessionMessage).toHaveBeenCalled();
228+
const lastCallIdx = client.addSessionMessage.mock.calls.length - 1;
229+
const createdAt = client.addSessionMessage.mock.calls[lastCallIdx][4] as string;
225230
expect(createdAt).toBe("2026-04-01T10:03:00.000Z");
226231
});
227232

228-
it("sanitizes <relevant-memories> from stored content", async () => {
233+
it("sanitizes <relevant-memories> from user content but not from assistant", async () => {
229234
const { engine, client } = makeEngine();
230235

231236
const messages = [
@@ -243,6 +248,7 @@ describe("context-engine afterTurn()", () => {
243248
});
244249

245250
expect(client.addSessionMessage).toHaveBeenCalledTimes(1);
251+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("user");
246252
const storedContent = client.addSessionMessage.mock.calls[0][2] as string;
247253
expect(storedContent).not.toContain("relevant-memories");
248254
expect(storedContent).not.toContain("injected memory data");
@@ -391,10 +397,12 @@ describe("context-engine afterTurn()", () => {
391397
prePromptMessageCount: 0,
392398
});
393399

394-
const storedContent = client.addSessionMessage.mock.calls[0][2] as string;
395-
expect(storedContent).toContain("src/app.ts");
396-
expect(storedContent).toContain("npm install");
397-
expect(storedContent).toContain("export const x = 1");
400+
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
401+
const userContent = client.addSessionMessage.mock.calls[0][2] as string;
402+
const assistantContent = client.addSessionMessage.mock.calls[1][2] as string;
403+
expect(userContent).toContain("src/app.ts");
404+
expect(userContent).toContain("npm install");
405+
expect(assistantContent).toContain("export const x = 1");
398406
});
399407

400408
it("passes agentId to addSessionMessage", async () => {
@@ -428,6 +436,143 @@ describe("context-engine afterTurn()", () => {
428436
expect(client.getSession).toHaveBeenCalled();
429437
});
430438

439+
it("maps toolResult to user role", async () => {
440+
const { engine, client } = makeEngine();
441+
442+
const messages = [
443+
{ role: "assistant", content: [
444+
{ type: "text", text: "running tool" },
445+
{ type: "toolUse", name: "bash", input: { cmd: "ls" } },
446+
] },
447+
{ role: "toolResult", toolName: "bash", content: "file1.txt\nfile2.txt" },
448+
{ role: "assistant", content: "done" },
449+
];
450+
451+
await engine.afterTurn!({
452+
sessionId: "s1",
453+
sessionFile: "",
454+
messages,
455+
prePromptMessageCount: 0,
456+
});
457+
458+
expect(client.addSessionMessage).toHaveBeenCalledTimes(3);
459+
// assistant → user(toolResult) → assistant
460+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("assistant");
461+
expect(client.addSessionMessage.mock.calls[1][1]).toBe("user");
462+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("[bash result]:");
463+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("file1.txt");
464+
expect(client.addSessionMessage.mock.calls[2][1]).toBe("assistant");
465+
});
466+
467+
it("merges adjacent same-role messages", async () => {
468+
const { engine, client } = makeEngine();
469+
470+
const messages = [
471+
{ role: "user", content: "first question" },
472+
{ role: "user", content: "second question" },
473+
{ role: "assistant", content: "answer" },
474+
];
475+
476+
await engine.afterTurn!({
477+
sessionId: "s1",
478+
sessionFile: "",
479+
messages,
480+
prePromptMessageCount: 0,
481+
});
482+
483+
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
484+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("user");
485+
expect(client.addSessionMessage.mock.calls[0][2]).toContain("first question");
486+
expect(client.addSessionMessage.mock.calls[0][2]).toContain("second question");
487+
expect(client.addSessionMessage.mock.calls[1][1]).toBe("assistant");
488+
});
489+
490+
it("merges adjacent toolResults into one user group", async () => {
491+
const { engine, client } = makeEngine();
492+
493+
const messages = [
494+
{ role: "assistant", content: [
495+
{ type: "text", text: "calling tools" },
496+
{ type: "toolUse", name: "read", input: { path: "a.txt" } },
497+
] },
498+
{ role: "toolResult", toolName: "read", content: "content of a" },
499+
{ role: "toolResult", toolName: "write", content: "ok" },
500+
{ role: "assistant", content: "all done" },
501+
];
502+
503+
await engine.afterTurn!({
504+
sessionId: "s1",
505+
sessionFile: "",
506+
messages,
507+
prePromptMessageCount: 0,
508+
});
509+
510+
expect(client.addSessionMessage).toHaveBeenCalledTimes(3);
511+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("assistant");
512+
// Two toolResults merged into one user call
513+
expect(client.addSessionMessage.mock.calls[1][1]).toBe("user");
514+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("[read result]:");
515+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("[write result]:");
516+
expect(client.addSessionMessage.mock.calls[2][1]).toBe("assistant");
517+
});
518+
519+
it("does not sanitize <relevant-memories> from assistant content", async () => {
520+
const { engine, client } = makeEngine();
521+
522+
const messages = [
523+
{ role: "user", content: "question" },
524+
{ role: "assistant", content: "Here is context <relevant-memories>data</relevant-memories> end" },
525+
];
526+
527+
await engine.afterTurn!({
528+
sessionId: "s1",
529+
sessionFile: "",
530+
messages,
531+
prePromptMessageCount: 0,
532+
});
533+
534+
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
535+
const assistantContent = client.addSessionMessage.mock.calls[1][2] as string;
536+
expect(assistantContent).toContain("relevant-memories");
537+
});
538+
539+
it("skips heartbeat messages from being stored", async () => {
540+
const { engine, client } = makeEngine();
541+
542+
const messages = [
543+
{ role: "user", content: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK." },
544+
{ role: "assistant", content: "HEARTBEAT_OK" },
545+
];
546+
547+
await engine.afterTurn!({
548+
sessionId: "s1",
549+
sessionFile: "",
550+
messages,
551+
prePromptMessageCount: 0,
552+
});
553+
554+
expect(client.addSessionMessage).not.toHaveBeenCalled();
555+
});
556+
557+
it("skips heartbeat via isHeartbeat flag", async () => {
558+
const { engine, client } = makeEngine();
559+
560+
const messages = [
561+
{ role: "user", content: "regular message" },
562+
{ role: "assistant", content: "reply" },
563+
];
564+
565+
await engine.afterTurn!({
566+
sessionId: "s1",
567+
sessionFile: "",
568+
messages,
569+
prePromptMessageCount: 0,
570+
isHeartbeat: true,
571+
});
572+
573+
expect(client.addSessionMessage).not.toHaveBeenCalled();
574+
});
575+
431576
it("skips store when all new messages are system only", async () => {
432577
const { engine, client } = makeEngine();
433578

examples/openclaw-plugin/text-utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,39 @@ function formatToolResultContent(content: unknown): string {
411411
return "";
412412
}
413413

414+
/**
415+
* Extract text from a single message without a `[role]:` prefix.
416+
* Used by afterTurn to send messages with their actual role.
417+
*/
418+
export function extractSingleMessageText(msg: unknown): string {
419+
if (!msg || typeof msg !== "object") return "";
420+
const m = msg as Record<string, unknown>;
421+
const role = m.role as string;
422+
if (!role || role === "system") return "";
423+
424+
if (role === "toolResult") {
425+
const toolName = typeof m.toolName === "string" ? m.toolName : "tool";
426+
const resultText = formatToolResultContent(m.content);
427+
return resultText ? `[${toolName} result]: ${resultText}` : "";
428+
}
429+
430+
const content = m.content;
431+
if (typeof content === "string") return content.trim();
432+
if (Array.isArray(content)) {
433+
const parts: string[] = [];
434+
for (const block of content) {
435+
const b = block as Record<string, unknown>;
436+
if (b?.type === "text" && typeof b.text === "string") {
437+
parts.push((b.text as string).trim());
438+
} else if (b?.type === "toolUse") {
439+
parts.push(formatToolUseBlock(b));
440+
}
441+
}
442+
return parts.join("\n");
443+
}
444+
return "";
445+
}
446+
414447
/**
415448
* 提取从 startIndex 开始的新消息(user + assistant + toolResult),返回格式化的文本。
416449
* 保留 toolUse 完整内容(tool name + input)和 toolResult 完整内容,

0 commit comments

Comments
 (0)