From 404209e1348cef9547fd5d6914a82562b1bc2bd9 Mon Sep 17 00:00:00 2001 From: Zylos Date: Mon, 20 Jul 2026 15:01:32 +0800 Subject: [PATCH] test: extract pure helpers and add unit tests to CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per repo policy every codebase needs an automated-test workflow; the Quality Gate previously only built. Context framing, media labels, priority→queue-mode mapping, and the [SKIP] sentinel move to a dependency-free helpers.ts (multi-file TS plugins are supported — the bundled feishu/whatsapp extensions do the same) so they run under node --test without the OpenClaw host, covering: structural-breakout escaping (incl. double-escape idempotence), group/quote/smart-hint framing, file-name label escaping, the SYSTEM-priority mapping table, and [SKIP] matching. CI Quality Gate now runs npm test. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 ++ helpers.ts | 75 ++++++++++++++++++++++++++++++++++++ index.ts | 83 ++++++---------------------------------- package.json | 3 ++ test/helpers.test.ts | 67 ++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 72 deletions(-) create mode 100644 helpers.ts create mode 100644 test/helpers.test.ts 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); +});