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
43 changes: 34 additions & 9 deletions examples/openclaw-plugin/context-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
compileSessionPatterns,
getCaptureDecision,
extractNewTurnTexts,
extractSingleMessageText,
shouldBypassSession,
} from "./text-utils.js";
import {
Expand Down Expand Up @@ -786,6 +787,10 @@ export function createMemoryOpenVikingContextEngine(params: {
return;
}

if (afterTurnParams.isHeartbeat) {
return;
}

try {
const sessionKey =
(typeof afterTurnParams.sessionKey === "string" && afterTurnParams.sessionKey.trim()) ||
Expand Down Expand Up @@ -865,19 +870,38 @@ export function createMemoryOpenVikingContextEngine(params: {
return;
}
const client = await getClient();
const turnText = newTexts.join("\n");
const sanitized = turnText.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ").replace(/\s+/g, " ").trim();
const createdAt = pickLatestCreatedAt(turnMessages);

if (sanitized) {
await client.addSessionMessage(OVSessionId, "user", sanitized, agentId, createdAt);
} else {
diag("afterTurn_skip", OVSessionId, {
reason: "sanitized_empty",
});
// Group by OV role (user|assistant), merge adjacent same-role
const HEARTBEAT_RE = /\bHEARTBEAT(?:\.md|_OK)\b/;
const groups: Array<{ role: "user" | "assistant"; texts: string[] }> = [];
for (const msg of turnMessages) {
const text = extractSingleMessageText(msg);
if (!text) continue;
if (HEARTBEAT_RE.test(text)) continue;
const role = (msg as Record<string, unknown>).role as string;
const ovRole: "user" | "assistant" = role === "assistant" ? "assistant" : "user";
const content = ovRole === "user"
? text.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ").replace(/\s+/g, " ").trim()
: text;
if (!content) continue;
const last = groups[groups.length - 1];
if (last && last.role === ovRole) {
last.texts.push(content);
} else {
groups.push({ role: ovRole, texts: [content] });
}
}

if (groups.length === 0) {
diag("afterTurn_skip", OVSessionId, { reason: "sanitized_empty" });
return;
}

for (const group of groups) {
await client.addSessionMessage(OVSessionId, group.role, group.texts.join("\n"), agentId, createdAt);
}

const session = await client.getSession(OVSessionId, agentId);
const pendingTokens = session.pending_tokens ?? 0;

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

const commitResult = await client.commitSession(OVSessionId, { wait: false, agentId });
const allTexts = groups.flatMap((g) => g.texts).join("\n");
const commitExtra = cfg.logFindRequests
? ` ${toJsonLog({ captured: [trimForLog(turnText, 260)] })}`
? ` ${toJsonLog({ captured: [trimForLog(allTexts, 260)] })}`
: "";
logger.info(
`openviking: committed session=${OVSessionId}, ` +
Expand Down
169 changes: 157 additions & 12 deletions examples/openclaw-plugin/tests/ut/context-engine-afterTurn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ describe("context-engine afterTurn()", () => {
);
});

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

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

expect(client.addSessionMessage).toHaveBeenCalledTimes(1);
const storedContent = client.addSessionMessage.mock.calls[0][2] as string;
expect(storedContent).toContain("hello world");
expect(storedContent).toContain("hi there");
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
// First call: user message
expect(client.addSessionMessage.mock.calls[0][1]).toBe("user");
expect(client.addSessionMessage.mock.calls[0][2]).toContain("hello world");
// Second call: assistant message
expect(client.addSessionMessage.mock.calls[1][1]).toBe("assistant");
expect(client.addSessionMessage.mock.calls[1][2]).toContain("hi there");
});

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

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

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

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

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

const storedContent = client.addSessionMessage.mock.calls[0][2] as string;
expect(storedContent).toContain("src/app.ts");
expect(storedContent).toContain("npm install");
expect(storedContent).toContain("export const x = 1");
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
const userContent = client.addSessionMessage.mock.calls[0][2] as string;
const assistantContent = client.addSessionMessage.mock.calls[1][2] as string;
expect(userContent).toContain("src/app.ts");
expect(userContent).toContain("npm install");
expect(assistantContent).toContain("export const x = 1");
});

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

it("maps toolResult to user role", async () => {
const { engine, client } = makeEngine();

const messages = [
{ role: "assistant", content: [
{ type: "text", text: "running tool" },
{ type: "toolUse", name: "bash", input: { cmd: "ls" } },
] },
{ role: "toolResult", toolName: "bash", content: "file1.txt\nfile2.txt" },
{ role: "assistant", content: "done" },
];

await engine.afterTurn!({
sessionId: "s1",
sessionFile: "",
messages,
prePromptMessageCount: 0,
});

expect(client.addSessionMessage).toHaveBeenCalledTimes(3);
// assistant → user(toolResult) → assistant
expect(client.addSessionMessage.mock.calls[0][1]).toBe("assistant");
expect(client.addSessionMessage.mock.calls[1][1]).toBe("user");
expect(client.addSessionMessage.mock.calls[1][2]).toContain("[bash result]:");
expect(client.addSessionMessage.mock.calls[1][2]).toContain("file1.txt");
expect(client.addSessionMessage.mock.calls[2][1]).toBe("assistant");
});

it("merges adjacent same-role messages", async () => {
const { engine, client } = makeEngine();

const messages = [
{ role: "user", content: "first question" },
{ role: "user", content: "second question" },
{ role: "assistant", content: "answer" },
];

await engine.afterTurn!({
sessionId: "s1",
sessionFile: "",
messages,
prePromptMessageCount: 0,
});

expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
expect(client.addSessionMessage.mock.calls[0][1]).toBe("user");
expect(client.addSessionMessage.mock.calls[0][2]).toContain("first question");
expect(client.addSessionMessage.mock.calls[0][2]).toContain("second question");
expect(client.addSessionMessage.mock.calls[1][1]).toBe("assistant");
});

it("merges adjacent toolResults into one user group", async () => {
const { engine, client } = makeEngine();

const messages = [
{ role: "assistant", content: [
{ type: "text", text: "calling tools" },
{ type: "toolUse", name: "read", input: { path: "a.txt" } },
] },
{ role: "toolResult", toolName: "read", content: "content of a" },
{ role: "toolResult", toolName: "write", content: "ok" },
{ role: "assistant", content: "all done" },
];

await engine.afterTurn!({
sessionId: "s1",
sessionFile: "",
messages,
prePromptMessageCount: 0,
});

expect(client.addSessionMessage).toHaveBeenCalledTimes(3);
expect(client.addSessionMessage.mock.calls[0][1]).toBe("assistant");
// Two toolResults merged into one user call
expect(client.addSessionMessage.mock.calls[1][1]).toBe("user");
expect(client.addSessionMessage.mock.calls[1][2]).toContain("[read result]:");
expect(client.addSessionMessage.mock.calls[1][2]).toContain("[write result]:");
expect(client.addSessionMessage.mock.calls[2][1]).toBe("assistant");
});

it("does not sanitize <relevant-memories> from assistant content", async () => {
const { engine, client } = makeEngine();

const messages = [
{ role: "user", content: "question" },
{ role: "assistant", content: "Here is context <relevant-memories>data</relevant-memories> end" },
];

await engine.afterTurn!({
sessionId: "s1",
sessionFile: "",
messages,
prePromptMessageCount: 0,
});

expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
const assistantContent = client.addSessionMessage.mock.calls[1][2] as string;
expect(assistantContent).toContain("relevant-memories");
});

it("skips heartbeat messages from being stored", async () => {
const { engine, client } = makeEngine();

const messages = [
{ 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." },
{ role: "assistant", content: "HEARTBEAT_OK" },
];

await engine.afterTurn!({
sessionId: "s1",
sessionFile: "",
messages,
prePromptMessageCount: 0,
});

expect(client.addSessionMessage).not.toHaveBeenCalled();
});

it("skips heartbeat via isHeartbeat flag", async () => {
const { engine, client } = makeEngine();

const messages = [
{ role: "user", content: "regular message" },
{ role: "assistant", content: "reply" },
];

await engine.afterTurn!({
sessionId: "s1",
sessionFile: "",
messages,
prePromptMessageCount: 0,
isHeartbeat: true,
});

expect(client.addSessionMessage).not.toHaveBeenCalled();
});

it("skips store when all new messages are system only", async () => {
const { engine, client } = makeEngine();

Expand Down
33 changes: 33 additions & 0 deletions examples/openclaw-plugin/text-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,39 @@ function formatToolResultContent(content: unknown): string {
return "";
}

/**
* Extract text from a single message without a `[role]:` prefix.
* Used by afterTurn to send messages with their actual role.
*/
export function extractSingleMessageText(msg: unknown): string {
if (!msg || typeof msg !== "object") return "";
const m = msg as Record<string, unknown>;
const role = m.role as string;
if (!role || role === "system") return "";

if (role === "toolResult") {
const toolName = typeof m.toolName === "string" ? m.toolName : "tool";
const resultText = formatToolResultContent(m.content);
return resultText ? `[${toolName} result]: ${resultText}` : "";
}

const content = m.content;
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
const parts: string[] = [];
for (const block of content) {
const b = block as Record<string, unknown>;
if (b?.type === "text" && typeof b.text === "string") {
parts.push((b.text as string).trim());
} else if (b?.type === "toolUse") {
parts.push(formatToolUseBlock(b));
}
}
return parts.join("\n");
}
return "";
}

/**
* 提取从 startIndex 开始的新消息(user + assistant + toolResult),返回格式化的文本。
* 保留 toolUse 完整内容(tool name + input)和 toolResult 完整内容,
Expand Down
Loading