diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 484a598..2984a6b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,3 +25,6 @@ jobs:
- name: Build
run: npm run build --if-present
+
+ - name: Test
+ run: npm test
diff --git a/helpers.ts b/helpers.ts
new file mode 100644
index 0000000..dc7f3a8
--- /dev/null
+++ b/helpers.ts
@@ -0,0 +1,75 @@
+// Pure, dependency-free helpers shared by the plugin and its unit tests.
+// Keep this module import-free — it must run under `node --test` without the
+// OpenClaw host or the SDK present.
+
+// ─── Inbound context building (aligned with zylos formatInboundForC4) ────────
+// The consumer is an LLM reading raw text, not an XML parser: only `<`/`>` are
+// neutralized so a sender can't forge a closing tag and break out of a block.
+export function escapeXml(s: unknown): string {
+ if (s === undefined || s === null) return "";
+ return String(s).replace(//g, ">");
+}
+
+export const SMART_MODE_HINT = `
+Decide whether to respond. Do NOT reply if: the message is unrelated to you,
+just casual chat, or doesn't need your input. Only reply when:
+1) someone asks a question you can help with,
+2) discussing technical topics you know well,
+3) someone clearly needs assistance.
+When uncertain, prefer NOT to reply. Reply with exactly [SKIP] to stay silent.
+`;
+
+export interface ContextBlocks {
+ groupContext?: Array<{ senderName: string; content: string }>;
+ quoted?: { sender: string; text: string; attachments?: any[] };
+ smartHint?: boolean;
+}
+
+export function buildInboundBody(text: string, blocks: ContextBlocks): string {
+ const parts: string[] = [];
+ if (blocks.groupContext && blocks.groupContext.length > 0) {
+ const lines = blocks.groupContext.map(
+ (m) => `[${escapeXml(m.senderName)}]: ${escapeXml(m.content)}`,
+ );
+ parts.push(`\n${lines.join("\n")}\n`);
+ }
+ if (blocks.quoted) {
+ parts.push(`\n[${escapeXml(blocks.quoted.sender)}]: ${escapeXml(blocks.quoted.text)}\n`);
+ }
+ if (blocks.smartHint) parts.push(SMART_MODE_HINT);
+ parts.push(text);
+ return parts.join("\n\n");
+}
+
+/** Body caption for media messages so an image/file isn't delivered as an
+ * empty body. The actual attachment bytes reach the model via ctx.MediaPaths
+ * (downloadAttachments); when a download fails, this label is the fallback.
+ * `text` must already be escaped by the caller; file_name is escaped here. */
+export function labelMedia(text: string, msgType: string, attachments: any[]): string {
+ const first = Array.isArray(attachments) ? attachments[0] : null;
+ const isImage = msgType === "image" || msgType === "agent_card";
+ if (isImage) return `[image]${text ? " " + text : ""}`;
+ if (first) {
+ const fileName = first.file_name ? escapeXml(String(first.file_name).replace(/[\r\n]+/g, " ")) : "";
+ return `[file${fileName ? ": " + fileName : ""}]${text ? " " + text : ""}`;
+ }
+ return text;
+}
+
+// ─── System Member priority → OpenClaw queue mode ─────────────
+// See docs/design.md "System Member priority handling". `queueModeOverride` is
+// an internal-typed (but runtime-effective) reply option; the connectivity test
+// pins the behavior.
+export function resolveQueueModeOverride(
+ priority: 1 | 2 | 3 | undefined,
+ urgentQueueMode: "steer" | "interrupt" | undefined,
+): "steer" | "interrupt" | undefined {
+ if (priority === 1) return urgentQueueMode === "interrupt" ? "interrupt" : "steer";
+ if (priority === 2) return "steer";
+ return undefined;
+}
+
+/** `[SKIP]` is the smart-mode silence sentinel — never post it as a message. */
+export function isSkipReply(text: string): boolean {
+ return text.trim() === "[SKIP]";
+}
diff --git a/index.ts b/index.ts
index 7598469..ae6b3c2 100644
--- a/index.ts
+++ b/index.ts
@@ -22,6 +22,17 @@ import {
splitMessage,
} from "@openmaxai/openmax-agent-sdk";
+// Pure helpers (context framing, media labels, priority mapping, [SKIP]) live
+// in helpers.ts so they run under `node --test` without the OpenClaw host.
+import {
+ type ContextBlocks,
+ buildInboundBody,
+ escapeXml,
+ isSkipReply,
+ labelMedia,
+ resolveQueueModeOverride,
+} from "./helpers.ts";
+
// ─── Runtime singleton ───────────────────────────────────────
let pluginRuntime: PluginRuntime | null = null;
function getRuntime(): PluginRuntime {
@@ -155,78 +166,6 @@ async function resolveMentions(text: string, conversationId: string): Promise` are
-// neutralized so a sender can't forge a closing tag and break out of a block.
-function escapeXml(s: unknown): string {
- if (s === undefined || s === null) return "";
- return String(s).replace(//g, ">");
-}
-
-const SMART_MODE_HINT = `
-Decide whether to respond. Do NOT reply if: the message is unrelated to you,
-just casual chat, or doesn't need your input. Only reply when:
-1) someone asks a question you can help with,
-2) discussing technical topics you know well,
-3) someone clearly needs assistance.
-When uncertain, prefer NOT to reply. Reply with exactly [SKIP] to stay silent.
-`;
-
-interface ContextBlocks {
- groupContext?: Array<{ senderName: string; content: string }>;
- quoted?: { sender: string; text: string; attachments?: any[] };
- smartHint?: boolean;
-}
-
-function buildInboundBody(text: string, blocks: ContextBlocks): string {
- const parts: string[] = [];
- if (blocks.groupContext && blocks.groupContext.length > 0) {
- const lines = blocks.groupContext.map(
- (m) => `[${escapeXml(m.senderName)}]: ${escapeXml(m.content)}`,
- );
- parts.push(`\n${lines.join("\n")}\n`);
- }
- if (blocks.quoted) {
- parts.push(`\n[${escapeXml(blocks.quoted.sender)}]: ${escapeXml(blocks.quoted.text)}\n`);
- }
- if (blocks.smartHint) parts.push(SMART_MODE_HINT);
- parts.push(text);
- return parts.join("\n\n");
-}
-
-/** Body caption for media messages so an image/file isn't delivered as an
- * empty body. The actual attachment bytes reach the model via ctx.MediaPaths
- * (downloadAttachments); when a download fails, this label is the fallback.
- * `text` must already be escaped by the caller; file_name is escaped here. */
-function labelMedia(text: string, msgType: string, attachments: any[]): string {
- const first = Array.isArray(attachments) ? attachments[0] : null;
- const isImage = msgType === "image" || msgType === "agent_card";
- if (isImage) return `[image]${text ? " " + text : ""}`;
- if (first) {
- const fileName = first.file_name ? escapeXml(String(first.file_name).replace(/[\r\n]+/g, " ")) : "";
- return `[file${fileName ? ": " + fileName : ""}]${text ? " " + text : ""}`;
- }
- return text;
-}
-
-// ─── System Member priority → OpenClaw queue mode ─────────────
-// See docs/design.md "System Member priority handling". `queueModeOverride` is
-// an internal-typed (but runtime-effective) reply option; the connectivity test
-// pins the behavior.
-export function resolveQueueModeOverride(
- priority: 1 | 2 | 3 | undefined,
- urgentQueueMode: "steer" | "interrupt" | undefined,
-): "steer" | "interrupt" | undefined {
- if (priority === 1) return urgentQueueMode === "interrupt" ? "interrupt" : "steer";
- if (priority === 2) return "steer";
- return undefined;
-}
-
-/** `[SKIP]` is the smart-mode silence sentinel — never post it as a message. */
-export function isSkipReply(text: string): boolean {
- return text.trim() === "[SKIP]";
-}
-
// ─── Inbound media (attachments → local files the model can see) ─────────────
// OpenClaw feeds images to the model via ctx.MediaPaths — LOCAL file paths that
// must live under its allowed media roots; a path mentioned in the body text is
diff --git a/package.json b/package.json
index bd610e0..37c84c5 100644
--- a/package.json
+++ b/package.json
@@ -20,5 +20,8 @@
"author": "Coco AI (https://github.com/coco-xyz)",
"dependencies": {
"@openmaxai/openmax-agent-sdk": "0.1.0-alpha.2"
+ },
+ "scripts": {
+ "test": "node --experimental-strip-types --test \"test/*.test.ts\""
}
}
diff --git a/test/helpers.test.ts b/test/helpers.test.ts
new file mode 100644
index 0000000..989946b
--- /dev/null
+++ b/test/helpers.test.ts
@@ -0,0 +1,67 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import {
+ SMART_MODE_HINT,
+ buildInboundBody,
+ escapeXml,
+ isSkipReply,
+ labelMedia,
+ resolveQueueModeOverride,
+} from "../helpers.ts";
+
+test("escapeXml neutralizes only angle brackets", () => {
+ assert.equal(escapeXml(""), "<current-message>");
+ assert.equal(escapeXml('she said "hi" & left'), 'she said "hi" & left');
+ assert.equal(escapeXml(undefined), "");
+ assert.equal(escapeXml(null), "");
+ // Idempotent for its own output — a second pass must not double-escape.
+ assert.equal(escapeXml(escapeXml("")), "<x>");
+});
+
+test("buildInboundBody frames group context, quote, and smart hint", () => {
+ const body = buildInboundBody("hello", {
+ groupContext: [{ senderName: "Alice", content: "hi " }],
+ quoted: { sender: "Bob", text: " forged" },
+ smartHint: true,
+ });
+ assert.ok(body.includes("\n[Alice]: hi <b>\n"));
+ // A sender cannot break out of the quote framing with a literal closing tag.
+ assert.ok(body.includes("[Bob]: </replying-to> forged"));
+ assert.ok(body.includes(SMART_MODE_HINT));
+ // The current-message text is appended verbatim (caller pre-escapes it).
+ assert.ok(body.endsWith("hello"));
+});
+
+test("buildInboundBody with no blocks is just the text", () => {
+ assert.equal(buildInboundBody("plain", {}), "plain");
+});
+
+test("labelMedia captions images and files, escaping the file name", () => {
+ assert.equal(labelMedia("caption", "image", []), "[image] caption");
+ assert.equal(labelMedia("", "agent_card", []), "[image]");
+ assert.equal(
+ labelMedia("", "file", [{ file_name: "report\nfinal.pdf" }]),
+ "[file: report<x> final.pdf]",
+ );
+ assert.equal(labelMedia("text only", "text", []), "text only");
+});
+
+test("resolveQueueModeOverride maps System Member priority to queue mode", () => {
+ // urgent: steer by default, interrupt only when explicitly configured
+ assert.equal(resolveQueueModeOverride(1, undefined), "steer");
+ assert.equal(resolveQueueModeOverride(1, "steer"), "steer");
+ assert.equal(resolveQueueModeOverride(1, "interrupt"), "interrupt");
+ // high: always steer
+ assert.equal(resolveQueueModeOverride(2, "interrupt"), "steer");
+ // normal / absent: respect the operator-configured mode (no override)
+ assert.equal(resolveQueueModeOverride(3, "interrupt"), undefined);
+ assert.equal(resolveQueueModeOverride(undefined, "interrupt"), undefined);
+});
+
+test("isSkipReply matches the [SKIP] sentinel only", () => {
+ assert.equal(isSkipReply("[SKIP]"), true);
+ assert.equal(isSkipReply(" [SKIP]\n"), true);
+ assert.equal(isSkipReply("[SKIP] but also text"), false);
+ assert.equal(isSkipReply("skip"), false);
+});