refactor(agent): dedupe harness and command workflows

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:09 +00:00
parent 04892ee230
commit f717a13039
204 changed files with 7366 additions and 11540 deletions

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { hasBalancedFences } from "../test-utils/chunk-test-helpers.js";
import {
chunkByNewline,
chunkMarkdownText,
@@ -11,22 +12,7 @@ import {
function expectFencesBalanced(chunks: string[]) {
for (const chunk of chunks) {
let open: { markerChar: string; markerLen: number } | null = null;
for (const line of chunk.split("\n")) {
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
if (!match) {
continue;
}
const marker = match[2];
if (!open) {
open = { markerChar: marker[0], markerLen: marker.length };
continue;
}
if (open.markerChar === marker[0] && marker.length >= open.markerLen) {
open = null;
}
}
expect(open).toBe(null);
expect(hasBalancedFences(chunk)).toBe(true);
}
}

View File

@@ -213,84 +213,48 @@ describe("resolveCommandAuthorization", () => {
});
describe("commands.allowFrom", () => {
it("uses commands.allowFrom global list when configured", () => {
const cfg = {
commands: {
allowFrom: {
"*": ["user123"],
},
const commandsAllowFromConfig = {
commands: {
allowFrom: {
"*": ["user123"],
},
channels: { whatsapp: { allowFrom: ["+different"] } },
} as OpenClawConfig;
},
channels: { whatsapp: { allowFrom: ["+different"] } },
} as OpenClawConfig;
const authorizedCtx = {
function makeWhatsAppContext(senderId: string): MsgContext {
return {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:user123",
SenderId: "user123",
From: `whatsapp:${senderId}`,
SenderId: senderId,
} as MsgContext;
}
const authorizedAuth = resolveCommandAuthorization({
ctx: authorizedCtx,
cfg,
commandAuthorized: true,
function resolveWithCommandsAllowFrom(senderId: string, commandAuthorized: boolean) {
return resolveCommandAuthorization({
ctx: makeWhatsAppContext(senderId),
cfg: commandsAllowFromConfig,
commandAuthorized,
});
}
it("uses commands.allowFrom global list when configured", () => {
const authorizedAuth = resolveWithCommandsAllowFrom("user123", true);
expect(authorizedAuth.isAuthorizedSender).toBe(true);
const unauthorizedCtx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:otheruser",
SenderId: "otheruser",
} as MsgContext;
const unauthorizedAuth = resolveCommandAuthorization({
ctx: unauthorizedCtx,
cfg,
commandAuthorized: true,
});
const unauthorizedAuth = resolveWithCommandsAllowFrom("otheruser", true);
expect(unauthorizedAuth.isAuthorizedSender).toBe(false);
});
it("ignores commandAuthorized when commands.allowFrom is configured", () => {
const cfg = {
commands: {
allowFrom: {
"*": ["user123"],
},
},
channels: { whatsapp: { allowFrom: ["+different"] } },
} as OpenClawConfig;
const authorizedCtx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:user123",
SenderId: "user123",
} as MsgContext;
const authorizedAuth = resolveCommandAuthorization({
ctx: authorizedCtx,
cfg,
commandAuthorized: false,
});
const authorizedAuth = resolveWithCommandsAllowFrom("user123", false);
expect(authorizedAuth.isAuthorizedSender).toBe(true);
const unauthorizedCtx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:otheruser",
SenderId: "otheruser",
} as MsgContext;
const unauthorizedAuth = resolveCommandAuthorization({
ctx: unauthorizedCtx,
cfg,
commandAuthorized: false,
});
const unauthorizedAuth = resolveWithCommandsAllowFrom("otheruser", false);
expect(unauthorizedAuth.isAuthorizedSender).toBe(false);
});

View File

@@ -171,6 +171,28 @@ describe("commands registry", () => {
});
describe("commands registry args", () => {
function createUsageModeCommand(
argsParsing: ChatCommandDefinition["argsParsing"] = "positional",
description = "mode",
): ChatCommandDefinition {
return {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing,
args: [
{
name: "mode",
description,
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
}
it("parses positional args and captureRemaining", () => {
const command: ChatCommandDefinition = {
key: "debug",
@@ -209,22 +231,7 @@ describe("commands registry args", () => {
});
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const command = createUsageModeCommand();
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
@@ -237,22 +244,7 @@ describe("commands registry args", () => {
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const command = createUsageModeCommand();
const menu = resolveCommandArgMenu({
command,
@@ -299,22 +291,7 @@ describe("commands registry args", () => {
});
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "none",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const command = createUsageModeCommand("none", "on or off");
const menu = resolveCommandArgMenu({
command,

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildInboundMediaNote } from "./media-note.js";
import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js";
describe("buildInboundMediaNote", () => {
it("formats single MediaPath as a media note", () => {
@@ -78,31 +79,7 @@ describe("buildInboundMediaNote", () => {
const note = buildInboundMediaNote({
MediaPaths: ["/tmp/a.png", "/tmp/b.png"],
MediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
MediaUnderstandingDecisions: [
{
capability: "image",
outcome: "success",
attachments: [
{
attachmentIndex: 0,
attempts: [
{
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
],
chosen: {
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
},
],
},
],
MediaUnderstandingDecisions: [createSuccessfulImageMediaDecision()],
});
expect(note).toBe("[media attached: /tmp/b.png | https://example.com/b.png]");
});

View File

@@ -0,0 +1,25 @@
export function createSuccessfulImageMediaDecision() {
return {
capability: "image",
outcome: "success",
attachments: [
{
attachmentIndex: 0,
attempts: [
{
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
],
chosen: {
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
},
],
} as const;
}

View File

@@ -7,6 +7,7 @@ import { getReplyFromConfig } from "./reply.js";
type RunEmbeddedPiAgent = typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent;
type RunEmbeddedPiAgentParams = Parameters<RunEmbeddedPiAgent>[0];
type RunEmbeddedPiAgentReply = Awaited<ReturnType<RunEmbeddedPiAgent>>;
const piEmbeddedMock = vi.hoisted(() => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
@@ -54,6 +55,58 @@ function restoreHomeEnv(snapshot: HomeEnvSnapshot) {
let fixtureRoot = "";
let caseId = 0;
function createEmbeddedReply(text: string): RunEmbeddedPiAgentReply {
return {
payloads: [{ text }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
}
function createTelegramMessage(messageSid: string) {
return {
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: messageSid,
Provider: "telegram",
} as const;
}
function createReplyConfig(home: string, streamMode?: "block") {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"], streamMode } },
session: { store: path.join(home, "sessions.json") },
};
}
async function runTelegramReply(params: {
home: string;
messageSid: string;
onBlockReply?: Parameters<typeof getReplyFromConfig>[1]["onBlockReply"];
onReplyStart?: Parameters<typeof getReplyFromConfig>[1]["onReplyStart"];
disableBlockStreaming?: boolean;
streamMode?: "block";
}) {
return getReplyFromConfig(
createTelegramMessage(params.messageSid),
{
onReplyStart: params.onReplyStart,
onBlockReply: params.onBlockReply,
disableBlockStreaming: params.disableBlockStreaming,
},
createReplyConfig(params.home, params.streamMode),
);
}
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const home = path.join(fixtureRoot, `case-${++caseId}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
@@ -135,38 +188,18 @@ describe("block streaming", () => {
void params.onBlockReply?.({ text: "second" });
return {
payloads: [{ text: "first" }, { text: "second" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
meta: createEmbeddedReply("first").meta,
};
};
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(impl);
const replyPromise = getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-123",
Provider: "telegram",
},
{
onReplyStart,
onBlockReply,
disableBlockStreaming: false,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const replyPromise = runTelegramReply({
home,
messageSid: "msg-123",
onReplyStart,
onBlockReply,
disableBlockStreaming: false,
});
await onReplyStartCalled;
releaseTyping?.();
@@ -176,37 +209,17 @@ describe("block streaming", () => {
expect(seen).toEqual(["first\n\nsecond"]);
const onBlockReplyStreamMode = vi.fn().mockResolvedValue(undefined);
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(async () => ({
payloads: [{ text: "final" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
}));
const resStreamMode = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-127",
Provider: "telegram",
},
{
onBlockReply: onBlockReplyStreamMode,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"], streamMode: "block" } },
session: { store: path.join(home, "sessions.json") },
},
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(async () =>
createEmbeddedReply("final"),
);
const resStreamMode = await runTelegramReply({
home,
messageSid: "msg-127",
onBlockReply: onBlockReplyStreamMode,
streamMode: "block",
});
expect(resStreamMode?.text).toBe("final");
expect(onBlockReplyStreamMode).not.toHaveBeenCalled();
});
@@ -222,39 +235,16 @@ describe("block streaming", () => {
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(
async (params: RunEmbeddedPiAgentParams) => {
void params.onBlockReply?.({ text: "\n\n Hello from stream" });
return {
payloads: [{ text: "\n\n Hello from stream" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
return createEmbeddedReply("\n\n Hello from stream");
},
);
const res = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-128",
Provider: "telegram",
},
{
onBlockReply,
disableBlockStreaming: false,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const res = await runTelegramReply({
home,
messageSid: "msg-128",
onBlockReply,
disableBlockStreaming: false,
});
expect(res).toBeUndefined();
expect(onBlockReply).toHaveBeenCalledTimes(1);
@@ -269,39 +259,16 @@ describe("block streaming", () => {
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(
async (params: RunEmbeddedPiAgentParams) => {
void params.onBlockReply?.({ text: "Result\nMEDIA: ./image.png" });
return {
payloads: [{ text: "Result\nMEDIA: ./image.png" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
return createEmbeddedReply("Result\nMEDIA: ./image.png");
},
);
const res = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-129",
Provider: "telegram",
},
{
onBlockReply,
disableBlockStreaming: false,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const res = await runTelegramReply({
home,
messageSid: "msg-129",
onBlockReply,
disableBlockStreaming: false,
});
expect(res).toBeUndefined();
expect(onBlockReply).toHaveBeenCalledTimes(1);

View File

@@ -4,7 +4,11 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
installDirectiveBehaviorE2EHooks,
makeWhatsAppDirectiveConfig,
replyText,
replyTexts,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
@@ -20,90 +24,38 @@ async function writeSkill(params: { workspaceDir: string; name: string; descript
);
}
async function runThinkingDirective(home: string, model: string) {
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
makeWhatsAppDirectiveConfig(home, { model }, { session: { store: sessionStorePath(home) } }),
);
return replyTexts(res);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("accepts /thinking xhigh for codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "openai-codex/gpt-5.2-codex",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = await runThinkingDirective(home, "openai-codex/gpt-5.2-codex");
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("accepts /thinking xhigh for openai gpt-5.2", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "openai/gpt-5.2",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = await runThinkingDirective(home, "openai/gpt-5.2");
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("rejects /thinking xhigh for non-codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "openai/gpt-4.1-mini",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = await runThinkingDirective(home, "openai/gpt-4.1-mini");
expect(texts).toContain(
'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.',
);
@@ -119,22 +71,19 @@ describe("directive behavior", () => {
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
},
makeWhatsAppDirectiveConfig(
home,
{
model: "anthropic/claude-opus-4-5",
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
{ session: { store: sessionStorePath(home) } },
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Help");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -156,19 +105,17 @@ describe("directive behavior", () => {
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace,
models: {
"anthropic/claude-opus-4-5": { alias: "demo_skill" },
},
makeWhatsAppDirectiveConfig(
home,
{
model: "anthropic/claude-opus-4-5",
workspace,
models: {
"anthropic/claude-opus-4-5": { alias: "demo_skill" },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
{ session: { store: sessionStorePath(home) } },
),
);
expect(runEmbeddedPiAgent).toHaveBeenCalled();
@@ -186,19 +133,16 @@ describe("directive behavior", () => {
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
session: { store: sessionStorePath(home) },
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Invalid debounce");
expect(text).toContain("Invalid cap");
expect(text).toContain("Invalid drop policy");
@@ -216,27 +160,24 @@ describe("directive behavior", () => {
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
messages: {
queue: {
mode: "collect",
debounceMs: 1500,
cap: 9,
drop: "summarize",
},
},
session: { store: sessionStorePath(home) },
},
messages: {
queue: {
mode: "collect",
debounceMs: 1500,
cap: 9,
drop: "summarize",
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain(
"Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.",
);
@@ -251,19 +192,14 @@ describe("directive behavior", () => {
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5", thinkingDefault: "high" },
{ session: { store: sessionStorePath(home) } },
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Current thinking level: high");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();

View File

@@ -1,57 +1,90 @@
import "./reply.directive.directive-behavior.e2e-mocks.js";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { loadSessionStore } from "../config/sessions.js";
import {
installDirectiveBehaviorE2EHooks,
makeWhatsAppDirectiveConfig,
replyText,
replyTexts,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
async function runThinkDirectiveAndGetText(
home: string,
options: { thinkingDefault?: "high" } = {},
): Promise<string | undefined> {
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(home, {
model: "anthropic/claude-opus-4-5",
...(options.thinkingDefault ? { thinkingDefault: options.thinkingDefault } : {}),
}),
);
return replyText(res);
}
function mockEmbeddedResponse(text: string) {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
}
async function runInlineReasoningMessage(params: {
home: string;
body: string;
storePath: string;
blockReplies: string[];
}) {
return await getReplyFromConfig(
{
Body: params.body,
From: "+1222",
To: "+1222",
Provider: "whatsapp",
},
{
onBlockReply: (payload) => {
if (payload.text) {
params.blockReplies.push(payload.text);
}
},
},
makeWhatsAppDirectiveConfig(
params.home,
{ model: "anthropic/claude-opus-4-5" },
{
session: { store: params.storePath },
},
),
);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("applies inline reasoning in mixed messages and acks immediately", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "done" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedResponse("done");
const blockReplies: string[] = [];
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
const res = await getReplyFromConfig(
{
Body: "please reply\n/reasoning on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
},
{
onBlockReply: (payload) => {
if (payload.text) {
blockReplies.push(payload.text);
}
},
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const res = await runInlineReasoningMessage({
home,
body: "please reply\n/reasoning on",
storePath,
blockReplies,
});
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = replyTexts(res);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
@@ -59,68 +92,24 @@ describe("directive behavior", () => {
});
it("keeps reasoning acks for rapid mixed directives", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedResponse("ok");
const blockReplies: string[] = [];
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
await getReplyFromConfig(
{
Body: "do it\n/reasoning on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
},
{
onBlockReply: (payload) => {
if (payload.text) {
blockReplies.push(payload.text);
}
},
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
await runInlineReasoningMessage({
home,
body: "do it\n/reasoning on",
storePath,
blockReplies,
});
await getReplyFromConfig(
{
Body: "again\n/reasoning on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
},
{
onBlockReply: (payload) => {
if (payload.text) {
blockReplies.push(payload.text);
}
},
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
await runInlineReasoningMessage({
home,
body: "again\n/reasoning on",
storePath,
blockReplies,
});
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
expect(blockReplies.length).toBe(0);
@@ -131,41 +120,31 @@ describe("directive behavior", () => {
const res = await getReplyFromConfig(
{ Body: "/verbose on", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
session: { store: path.join(home, "sessions.json") },
},
makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toMatch(/^⚙️ Verbose logging enabled\./);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("persists verbose off when directive is standalone", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
const res = await getReplyFromConfig(
{ Body: "/verbose off", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
session: { store: storePath },
},
session: { store: storePath },
},
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toMatch(/Verbose logging disabled\./);
const store = loadSessionStore(storePath);
const entry = Object.values(store)[0];
@@ -175,22 +154,7 @@ describe("directive behavior", () => {
});
it("shows current think level when /think has no argument", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runThinkDirectiveAndGetText(home, { thinkingDefault: "high" });
expect(text).toContain("Current thinking level: high");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -198,21 +162,7 @@ describe("directive behavior", () => {
});
it("shows off when /think has no argument and no default set", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runThinkDirectiveAndGetText(home);
expect(text).toContain("Current thinking level: off");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();

View File

@@ -20,6 +20,22 @@ export const DEFAULT_TEST_MODEL_CATALOG: Array<{
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
];
export type ReplyPayloadText = { text?: string | null } | null | undefined;
export function replyText(res: ReplyPayloadText | ReplyPayloadText[]): string | undefined {
if (Array.isArray(res)) {
return typeof res[0]?.text === "string" ? res[0]?.text : undefined;
}
return typeof res?.text === "string" ? res.text : undefined;
}
export function replyTexts(res: ReplyPayloadText | ReplyPayloadText[]): string[] {
const payloads = Array.isArray(res) ? res : [res];
return payloads
.map((entry) => (typeof entry?.text === "string" ? entry.text : undefined))
.filter((value): value is string => Boolean(value));
}
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(
async (home) => {
@@ -35,6 +51,55 @@ export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise
);
}
export function sessionStorePath(home: string): string {
return path.join(home, "sessions.json");
}
export function makeWhatsAppDirectiveConfig(
home: string,
defaults: Record<string, unknown>,
extra: Record<string, unknown> = {},
) {
return {
agents: {
defaults: {
workspace: path.join(home, "openclaw"),
...defaults,
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: sessionStorePath(home) },
...extra,
};
}
export const AUTHORIZED_WHATSAPP_COMMAND = {
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
} as const;
export function makeElevatedDirectiveConfig(home: string) {
return makeWhatsAppDirectiveConfig(
home,
{
model: "anthropic/claude-opus-4-5",
elevatedDefault: "on",
},
{
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: sessionStorePath(home) },
},
);
}
export function assertModelSelection(
storePath: string,
selection: { model?: string; provider?: string } = {},

View File

@@ -1,41 +1,50 @@
import "./reply.directive.directive-behavior.e2e-mocks.js";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
assertModelSelection,
installDirectiveBehaviorE2EHooks,
loadModelCatalog,
makeWhatsAppDirectiveConfig,
replyText,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
async function runModelDirective(
home: string,
body: string,
options: {
defaults?: Record<string, unknown>;
extra?: Record<string, unknown>;
} = {},
): Promise<string | undefined> {
const res = await getReplyFromConfig(
{ Body: body, From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(
home,
{
model: { primary: "anthropic/claude-opus-4-5" },
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
...options.defaults,
},
{ session: { store: sessionStorePath(home) }, ...options.extra },
),
);
return replyText(res);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("aliases /model list to /models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runModelDirective(home, "/model list");
expect(text).toContain("Providers:");
expect(text).toContain("- anthropic");
expect(text).toContain("- openai");
@@ -47,27 +56,7 @@ describe("directive behavior", () => {
it("shows current model when catalog is unavailable", async () => {
await withTempHome(async (home) => {
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runModelDirective(home, "/model");
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
@@ -82,27 +71,16 @@ describe("directive behavior", () => {
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
{ id: "grok-4", name: "Grok 4", provider: "xai" },
]);
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["openai/gpt-4.1-mini"],
},
imageModel: { primary: "minimax/MiniMax-M2.1" },
workspace: path.join(home, "openclaw"),
},
const text = await runModelDirective(home, "/model list", {
defaults: {
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["openai/gpt-4.1-mini"],
},
session: { store: storePath },
imageModel: { primary: "minimax/MiniMax-M2.1" },
models: undefined,
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
});
expect(text).toContain("Providers:");
expect(text).toContain("- anthropic");
expect(text).toContain("- openai");
@@ -123,23 +101,15 @@ describe("directive behavior", () => {
},
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
]);
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
"minimax/MiniMax-M2.1": { alias: "minimax" },
},
},
const text = await runModelDirective(home, "/models minimax", {
defaults: {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
"minimax/MiniMax-M2.1": { alias: "minimax" },
},
},
extra: {
models: {
mode: "merge",
providers: {
@@ -150,11 +120,8 @@ describe("directive behavior", () => {
},
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
});
expect(text).toContain("Models (minimax)");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -162,26 +129,13 @@ describe("directive behavior", () => {
});
it("does not repeat missing auth labels on /model list", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": {},
},
},
const text = await runModelDirective(home, "/model list", {
defaults: {
models: {
"anthropic/claude-opus-4-5": {},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
});
expect(text).toContain("Providers:");
expect(text).not.toContain("missing (missing)");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -189,24 +143,22 @@ describe("directive behavior", () => {
});
it("sets model override on /model directive", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
await getReplyFromConfig(
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
makeWhatsAppDirectiveConfig(
home,
{
model: { primary: "anthropic/claude-opus-4-5" },
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
session: { store: storePath },
},
{ session: { store: storePath } },
),
);
assertModelSelection(storePath, {
@@ -218,24 +170,22 @@ describe("directive behavior", () => {
});
it("supports model aliases on /model directive", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
await getReplyFromConfig(
{ Body: "/model Opus", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "openclaw"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
makeWhatsAppDirectiveConfig(
home,
{
model: { primary: "openai/gpt-4.1-mini" },
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
session: { store: storePath },
},
{ session: { store: storePath } },
),
);
assertModelSelection(storePath, {

View File

@@ -1,20 +1,33 @@
import "./reply.directive.directive-behavior.e2e-mocks.js";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
installDirectiveBehaviorE2EHooks,
makeWhatsAppDirectiveConfig,
replyText,
runEmbeddedPiAgent,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
function makeWorkElevatedAllowlistConfig(home: string) {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
const base = makeWhatsAppDirectiveConfig(
home,
{
model: "anthropic/claude-opus-4-5",
},
{
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222", "+1333"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222", "+1333"] } },
},
);
return {
...base,
agents: {
...base.agents,
list: [
{
id: "work",
@@ -26,13 +39,17 @@ function makeWorkElevatedAllowlistConfig(home: string) {
},
],
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222", "+1333"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222", "+1333"] } },
session: { store: path.join(home, "sessions.json") },
};
}
function makeCommandMessage(body: string, from = "+1222") {
return {
Body: body,
From: from,
To: from,
Provider: "whatsapp",
SenderE164: from,
CommandAuthorized: true,
} as const;
}
@@ -55,7 +72,7 @@ describe("directive behavior", () => {
makeWorkElevatedAllowlistConfig(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("agents.list[].tools.elevated.allowFrom.whatsapp");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -64,19 +81,14 @@ describe("directive behavior", () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "+1333",
To: "+1333",
Provider: "whatsapp",
SenderE164: "+1333",
...makeCommandMessage("/elevated on", "+1333"),
SessionKey: "agent:work:main",
CommandAuthorized: true,
},
{},
makeWorkElevatedAllowlistConfig(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Elevated mode set to ask");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -84,34 +96,26 @@ describe("directive behavior", () => {
it("warns when elevated is used in direct runtime", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/elevated off",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
makeCommandMessage("/elevated off"),
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
sandbox: { mode: "off" },
},
makeWhatsAppDirectiveConfig(
home,
{
model: "anthropic/claude-opus-4-5",
sandbox: { mode: "off" },
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
{
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("Runtime is direct; sandboxing does not apply.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -120,33 +124,23 @@ describe("directive behavior", () => {
it("rejects invalid elevated level", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/elevated maybe",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
makeCommandMessage("/elevated maybe"),
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Unrecognized elevated level");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -154,33 +148,23 @@ describe("directive behavior", () => {
it("handles multiple directives in a single message", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/elevated off\n/verbose on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
makeCommandMessage("/elevated off\n/verbose on"),
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("Verbose logging enabled.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();

View File

@@ -13,6 +13,31 @@ import { getReplyFromConfig } from "./reply.js";
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
function extractReplyText(res: Awaited<ReturnType<typeof getReplyFromConfig>>): string {
return (Array.isArray(res) ? res[0]?.text : res?.text) ?? "";
}
function makeQueueDirectiveConfig(home: string, storePath: string) {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
}
async function runQueueDirective(params: { home: string; storePath: string; body: string }) {
return await getReplyFromConfig(
{ Body: params.body, From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeQueueDirectiveConfig(params.home, params.storePath),
);
}
it("returns status alongside directive-only acks", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
@@ -44,7 +69,7 @@ describe("directive behavior", () => {
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = extractReplyText(res);
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("Session: agent:main:main");
const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️"));
@@ -72,7 +97,7 @@ describe("directive behavior", () => {
makeRestrictedElevatedDisabledConfig(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = extractReplyText(res);
expect(text).not.toContain("elevated");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -81,22 +106,13 @@ describe("directive behavior", () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/queue interrupt", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const res = await runQueueDirective({
home,
storePath,
body: "/queue interrupt",
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = extractReplyText(res);
expect(text).toMatch(/^⚙️ Queue mode set to interrupt\./);
const store = loadSessionStore(storePath);
const entry = Object.values(store)[0];
@@ -108,27 +124,13 @@ describe("directive behavior", () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/queue collect debounce:2s cap:5 drop:old",
From: "+1222",
To: "+1222",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const res = await runQueueDirective({
home,
storePath,
body: "/queue collect debounce:2s cap:5 drop:old",
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = extractReplyText(res);
expect(text).toMatch(/^⚙️ Queue mode set to collect\./);
expect(text).toMatch(/Queue debounce set to 2000ms/);
expect(text).toMatch(/Queue cap set to 5/);
@@ -146,37 +148,9 @@ describe("directive behavior", () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
{ Body: "/queue interrupt", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const res = await getReplyFromConfig(
{ Body: "/queue reset", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
await runQueueDirective({ home, storePath, body: "/queue interrupt" });
const res = await runQueueDirective({ home, storePath, body: "/queue reset" });
const text = extractReplyText(res);
expect(text).toMatch(/^⚙️ Queue mode reset to default\./);
const store = loadSessionStore(storePath);
const entry = Object.values(store)[0];

View File

@@ -1,143 +1,48 @@
import "./reply.directive.directive-behavior.e2e-mocks.js";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadSessionStore } from "../config/sessions.js";
import {
AUTHORIZED_WHATSAPP_COMMAND,
installDirectiveBehaviorE2EHooks,
makeElevatedDirectiveConfig,
replyText,
makeRestrictedElevatedDisabledConfig,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
async function runAuthorizedCommand(home: string, body: string) {
return getReplyFromConfig(
{
...AUTHORIZED_WHATSAPP_COMMAND,
Body: body,
},
{},
makeElevatedDirectiveConfig(home),
);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("shows current elevated level as off after toggling it off", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
{
Body: "/elevated off",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
elevatedDefault: "on",
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
},
);
const res = await getReplyFromConfig(
{
Body: "/elevated",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
elevatedDefault: "on",
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
await runAuthorizedCommand(home, "/elevated off");
const res = await runAuthorizedCommand(home, "/elevated");
const text = replyText(res);
expect(text).toContain("Current elevated level: off");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("can toggle elevated off then back on (status reflects on)", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
elevatedDefault: "on",
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
} as const;
await getReplyFromConfig(
{
Body: "/elevated off",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
{},
cfg,
);
await getReplyFromConfig(
{
Body: "/elevated on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
{},
cfg,
);
const res = await getReplyFromConfig(
{
Body: "/status",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const storePath = sessionStorePath(home);
await runAuthorizedCommand(home, "/elevated off");
await runAuthorizedCommand(home, "/elevated on");
const res = await runAuthorizedCommand(home, "/status");
const text = replyText(res);
const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️"));
expect(optionsLine).toBeTruthy();
expect(optionsLine).toContain("elevated");
@@ -163,7 +68,7 @@ describe("directive behavior", () => {
makeRestrictedElevatedDisabledConfig(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("agents.list[].tools.elevated.enabled");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});

View File

@@ -1,35 +1,58 @@
import "./reply.directive.directive-behavior.e2e-mocks.js";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { loadSessionStore } from "../config/sessions.js";
import {
AUTHORIZED_WHATSAPP_COMMAND,
installDirectiveBehaviorE2EHooks,
makeElevatedDirectiveConfig,
makeWhatsAppDirectiveConfig,
replyText,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
const COMMAND_MESSAGE_BASE = {
From: "+1222",
To: "+1222",
CommandAuthorized: true,
} as const;
async function runCommand(
home: string,
body: string,
options: { defaults?: Record<string, unknown>; extra?: Record<string, unknown> } = {},
) {
const res = await getReplyFromConfig(
{ ...COMMAND_MESSAGE_BASE, Body: body },
{},
makeWhatsAppDirectiveConfig(
home,
{
model: "anthropic/claude-opus-4-5",
...options.defaults,
},
options.extra ?? {},
),
);
return replyText(res);
}
async function runElevatedCommand(home: string, body: string) {
return getReplyFromConfig(
{ ...AUTHORIZED_WHATSAPP_COMMAND, Body: body },
{},
makeElevatedDirectiveConfig(home),
);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("shows current verbose level when /verbose has no argument", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{ Body: "/verbose", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
verboseDefault: "on",
},
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runCommand(home, "/verbose", { defaults: { verboseDefault: "on" } });
expect(text).toContain("Current verbose level: on");
expect(text).toContain("Options: on, full, off.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -37,21 +60,7 @@ describe("directive behavior", () => {
});
it("shows current reasoning level when /reasoning has no argument", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{ Body: "/reasoning", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runCommand(home, "/reasoning");
expect(text).toContain("Current reasoning level: off");
expect(text).toContain("Options: on, off, stream.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -59,35 +68,8 @@ describe("directive behavior", () => {
});
it("shows current elevated level when /elevated has no argument", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/elevated",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
elevatedDefault: "on",
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const res = await runElevatedCommand(home, "/elevated");
const text = replyText(res);
expect(text).toContain("Current elevated level: on");
expect(text).toContain("Options: on, off, ask, full.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -95,21 +77,8 @@ describe("directive behavior", () => {
});
it("shows current exec defaults when /exec has no argument", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/exec",
From: "+1222",
To: "+1222",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
const text = await runCommand(home, "/exec", {
extra: {
tools: {
exec: {
host: "gateway",
@@ -118,11 +87,8 @@ describe("directive behavior", () => {
node: "mac-1",
},
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
});
expect(text).toContain(
"Current exec defaults: host=gateway, security=allowlist, ask=always, node=mac-1.",
);
@@ -134,37 +100,9 @@ describe("directive behavior", () => {
});
it("persists elevated off and reflects it in /status (even when default is on)", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/elevated off\n/status",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
elevatedDefault: "on",
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const storePath = sessionStorePath(home);
const res = await runElevatedCommand(home, "/elevated off\n/status");
const text = replyText(res);
expect(text).toContain("Elevated mode disabled.");
const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️"));
expect(optionsLine).toBeTruthy();
@@ -184,7 +122,7 @@ describe("directive behavior", () => {
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
await getReplyFromConfig(
{
@@ -195,22 +133,7 @@ describe("directive behavior", () => {
SenderE164: "+1222",
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
elevatedDefault: "on",
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
},
makeElevatedDirectiveConfig(home),
);
const store = loadSessionStore(storePath);

View File

@@ -39,66 +39,68 @@ function makeMoonshotConfig(home: string, storePath: string) {
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
async function runMoonshotModelDirective(params: {
home: string;
storePath: string;
body: string;
}) {
return await getReplyFromConfig(
{ Body: params.body, From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeMoonshotConfig(params.home, params.storePath),
);
}
function expectMoonshotSelectionFromResponse(params: {
response: Awaited<ReturnType<typeof getReplyFromConfig>>;
storePath: string;
}) {
const text = Array.isArray(params.response) ? params.response[0]?.text : params.response?.text;
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
assertModelSelection(params.storePath, {
provider: "moonshot",
model: "kimi-k2-0905-preview",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}
it("supports fuzzy model matches on /model directive", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeMoonshotConfig(home, storePath),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
assertModelSelection(storePath, {
provider: "moonshot",
model: "kimi-k2-0905-preview",
const res = await runMoonshotModelDirective({
home,
storePath,
body: "/model kimi",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expectMoonshotSelectionFromResponse({ response: res, storePath });
});
});
it("resolves provider-less exact model ids via fuzzy matching when unambiguous", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/model kimi-k2-0905-preview",
From: "+1222",
To: "+1222",
CommandAuthorized: true,
},
{},
makeMoonshotConfig(home, storePath),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
assertModelSelection(storePath, {
provider: "moonshot",
model: "kimi-k2-0905-preview",
const res = await runMoonshotModelDirective({
home,
storePath,
body: "/model kimi-k2-0905-preview",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expectMoonshotSelectionFromResponse({ response: res, storePath });
});
});
it("supports fuzzy matches within a provider on /model provider/model", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeMoonshotConfig(home, storePath),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
assertModelSelection(storePath, {
provider: "moonshot",
model: "kimi-k2-0905-preview",
const res = await runMoonshotModelDirective({
home,
storePath,
body: "/model moonshot/kimi",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expectMoonshotSelectionFromResponse({ response: res, storePath });
});
});
it("picks the best fuzzy match when multiple models match", async () => {

View File

@@ -1,20 +1,41 @@
import "./reply.directive.directive-behavior.e2e-mocks.js";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { loadSessionStore, resolveSessionKey, saveSessionStore } from "../config/sessions.js";
import {
installDirectiveBehaviorE2EHooks,
makeWhatsAppDirectiveConfig,
replyText,
replyTexts,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
async function runModelDirectiveAndGetText(
home: string,
body: string,
): Promise<string | undefined> {
const res = await getReplyFromConfig(
{ Body: body, From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(home, {
model: { primary: "anthropic/claude-opus-4-5" },
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}),
);
return replyText(res);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("updates tool verbose during an in-flight run (toggle on)", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
const ctx = { Body: "please do the thing", From: "+1004", To: "+2000" };
const sessionKey = resolveSessionKey(
"per-sender",
@@ -49,26 +70,23 @@ describe("directive behavior", () => {
const res = await getReplyFromConfig(
ctx,
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
session: { store: storePath },
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
),
);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = replyTexts(res);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("updates tool verbose during an in-flight run (toggle off)", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
const ctx = {
Body: "please do the thing",
From: "+1004",
@@ -107,61 +125,35 @@ describe("directive behavior", () => {
await getReplyFromConfig(
{ Body: "/verbose on", From: ctx.From, To: ctx.To, CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
session: { store: storePath },
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
),
);
const res = await getReplyFromConfig(
ctx,
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
session: { store: storePath },
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
),
);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = replyTexts(res);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("shows summary on /model", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runModelDirectiveAndGetText(home, "/model");
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
@@ -172,27 +164,7 @@ describe("directive behavior", () => {
});
it("lists allowlisted models on /model status", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model status", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runModelDirectiveAndGetText(home, "/model status");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");

View File

@@ -1,106 +1,25 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { beforeAll, describe, expect, it } from "vitest";
import {
getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks,
makeCfg,
withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
const usageMocks = vi.hoisted(() => ({
loadProviderUsageSummary: vi.fn().mockResolvedValue({
updatedAt: 0,
providers: [],
}),
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
}));
vi.mock("../infra/provider-usage.js", () => usageMocks);
const modelCatalogMocks = vi.hoisted(() => ({
loadModelCatalog: vi.fn().mockResolvedValue([
{
provider: "anthropic",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
contextWindow: 200000,
},
{
provider: "openrouter",
id: "anthropic/claude-opus-4-5",
name: "Claude Opus 4.5 (OpenRouter)",
contextWindow: 200000,
},
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
{ provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" },
{ provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" },
]),
resetModelCatalogCacheForTest: vi.fn(),
}));
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
const webMocks = vi.hoisted(() => ({
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
}));
vi.mock("../web/session.js", () => webMocks);
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(
async (home) => {
await mkdir(join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(abortEmbeddedPiRun).mockClear();
return await fn(home);
},
{ prefix: "openclaw-triggers-" },
);
}
function makeCfg(home: string) {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: { store: join(home, "sessions.json") },
};
}
afterEach(() => {
vi.restoreAllMocks();
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
beforeAll(async () => {
({ getReplyFromConfig } = await import("./reply.js"));
});
installTriggerHandlingE2eTestHooks();
describe("group intro prompts", () => {
const groupParticipationNote =
"Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available. Write like a human. Avoid Markdown tables. Don't type literal \\n sequences; use real line breaks sparingly.";
it("labels Discord groups using the surface metadata", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
getRunEmbeddedPiAgentMock().mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
@@ -122,9 +41,9 @@ describe("group intro prompts", () => {
makeCfg(home),
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
const extraSystemPrompt =
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toContain('"channel": "discord"');
expect(extraSystemPrompt).toContain(
`You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`,
@@ -136,7 +55,7 @@ describe("group intro prompts", () => {
});
it("keeps WhatsApp labeling for WhatsApp group chats", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
getRunEmbeddedPiAgentMock().mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
@@ -157,9 +76,9 @@ describe("group intro prompts", () => {
makeCfg(home),
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
const extraSystemPrompt =
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toContain('"channel": "whatsapp"');
expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`);
expect(extraSystemPrompt).toContain(
@@ -172,7 +91,7 @@ describe("group intro prompts", () => {
});
it("labels Telegram groups using their own surface", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
getRunEmbeddedPiAgentMock().mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
@@ -193,9 +112,9 @@ describe("group intro prompts", () => {
makeCfg(home),
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
const extraSystemPrompt =
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toContain('"channel": "telegram"');
expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`);
expect(extraSystemPrompt).toContain(

View File

@@ -1,10 +1,11 @@
import fs from "node:fs/promises";
import { join } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import {
getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks,
MAIN_SESSION_KEY,
makeWhatsAppElevatedCfg,
runDirectElevatedToggleAndLoadStore,
withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js";
@@ -18,68 +19,18 @@ installTriggerHandlingE2eTestHooks();
describe("trigger handling", () => {
it("allows approved sender to toggle elevated mode", async () => {
await withTempHome(async (home) => {
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
{},
const cfg = makeWhatsAppElevatedCfg(home);
const { text, store } = await runDirectElevatedToggleAndLoadStore({
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
getReplyFromConfig,
});
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
});
});
it("rejects elevated toggles when disabled", async () => {
await withTempHome(async (home) => {
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
},
},
tools: {
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+1000"] },
},
},
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
const cfg = makeWhatsAppElevatedCfg(home, { elevatedEnabled: false });
const res = await getReplyFromConfig(
{
@@ -109,26 +60,7 @@ describe("trigger handling", () => {
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
channels: {
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
},
},
session: { store: join(home, "sessions.json") },
};
const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false });
const res = await getReplyFromConfig(
{

View File

@@ -4,7 +4,8 @@ import { loadSessionStore } from "../config/sessions.js";
import {
installTriggerHandlingE2eTestHooks,
MAIN_SESSION_KEY,
makeCfg,
makeWhatsAppElevatedCfg,
runDirectElevatedToggleAndLoadStore,
withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js";
@@ -18,22 +19,7 @@ installTriggerHandlingE2eTestHooks();
describe("trigger handling", () => {
it("allows elevated off in groups without mention", async () => {
await withTempHome(async (home) => {
const baseCfg = makeCfg(home);
const cfg = {
...baseCfg,
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
channels: {
...baseCfg.channels,
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
},
},
};
const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false });
const res = await getReplyFromConfig(
{
@@ -59,22 +45,7 @@ describe("trigger handling", () => {
it("allows elevated directive in groups when mentioned", async () => {
await withTempHome(async (home) => {
const baseCfg = makeCfg(home);
const cfg = {
...baseCfg,
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
channels: {
...baseCfg.channels,
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: true } },
},
},
};
const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: true });
const res = await getReplyFromConfig(
{
@@ -101,39 +72,12 @@ describe("trigger handling", () => {
it("allows elevated directive in direct chats without mentions", async () => {
await withTempHome(async (home) => {
const baseCfg = makeCfg(home);
const cfg = {
...baseCfg,
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
channels: {
...baseCfg.channels,
whatsapp: {
allowFrom: ["+1000"],
},
},
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
{},
const cfg = makeWhatsAppElevatedCfg(home);
const { text, store } = await runDirectElevatedToggleAndLoadStore({
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
getReplyFromConfig,
});
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
});
});

View File

@@ -3,6 +3,7 @@ import { join } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import {
createBlockReplyCollector,
getProviderUsageMocks,
getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks,
@@ -29,6 +30,29 @@ function pickFirstStoreEntry<T>(store: Record<string, unknown>): T | undefined {
return entries[0];
}
async function runCommandAndCollectReplies(params: {
home: string;
body: string;
from?: string;
senderE164?: string;
}) {
const { blockReplies, handlers } = createBlockReplyCollector();
const res = await getReplyFromConfig(
{
Body: params.body,
From: params.from ?? "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: params.senderE164 ?? params.from ?? "+1000",
CommandAuthorized: true,
},
handlers,
makeCfg(params.home),
);
const replies = res ? (Array.isArray(res) ? res : [res]) : [];
return { blockReplies, replies };
}
describe("trigger handling", () => {
it("filters usage summary to the current model provider", async () => {
await withTempHome(async (home) => {
@@ -71,24 +95,10 @@ describe("trigger handling", () => {
});
it("emits /status once (no duplicate inline + final)", async () => {
await withTempHome(async (home) => {
const blockReplies: Array<{ text?: string }> = [];
const res = await getReplyFromConfig(
{
Body: "/status",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
{
onBlockReply: async (payload) => {
blockReplies.push(payload);
},
},
makeCfg(home),
);
const replies = res ? (Array.isArray(res) ? res : [res]) : [];
const { blockReplies, replies } = await runCommandAndCollectReplies({
home,
body: "/status",
});
expect(blockReplies.length).toBe(0);
expect(replies.length).toBe(1);
expect(String(replies[0]?.text ?? "")).toContain("Model:");
@@ -96,24 +106,10 @@ describe("trigger handling", () => {
});
it("sets per-response usage footer via /usage", async () => {
await withTempHome(async (home) => {
const blockReplies: Array<{ text?: string }> = [];
const res = await getReplyFromConfig(
{
Body: "/usage tokens",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
{
onBlockReply: async (payload) => {
blockReplies.push(payload);
},
},
makeCfg(home),
);
const replies = res ? (Array.isArray(res) ? res : [res]) : [];
const { blockReplies, replies } = await runCommandAndCollectReplies({
home,
body: "/usage tokens",
});
expect(blockReplies.length).toBe(0);
expect(replies.length).toBe(1);
expect(String(replies[0]?.text ?? "")).toContain("Usage footer: tokens");
@@ -217,24 +213,11 @@ describe("trigger handling", () => {
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const blockReplies: Array<{ text?: string }> = [];
const res = await getReplyFromConfig(
{
Body: "here we go /status now",
From: "+1002",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1002",
CommandAuthorized: true,
},
{
onBlockReply: async (payload) => {
blockReplies.push(payload);
},
},
makeCfg(home),
);
const replies = res ? (Array.isArray(res) ? res : [res]) : [];
const { blockReplies, replies } = await runCommandAndCollectReplies({
home,
body: "here we go /status now",
from: "+1002",
});
expect(blockReplies.length).toBe(1);
expect(String(blockReplies[0]?.text ?? "")).toContain("Model:");
expect(replies.length).toBe(1);

View File

@@ -1,8 +1,10 @@
import { beforeAll, describe, expect, it } from "vitest";
import {
createBlockReplyCollector,
getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks,
makeCfg,
mockRunEmbeddedPiAgentOk,
withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js";
@@ -16,16 +18,8 @@ installTriggerHandlingE2eTestHooks();
describe("trigger handling", () => {
it("handles inline /commands and strips it before the agent", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const blockReplies: Array<{ text?: string }> = [];
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk();
const { blockReplies, handlers } = createBlockReplyCollector();
const res = await getReplyFromConfig(
{
Body: "please /commands now",
@@ -33,11 +27,7 @@ describe("trigger handling", () => {
To: "+2000",
CommandAuthorized: true,
},
{
onBlockReply: async (payload) => {
blockReplies.push(payload);
},
},
handlers,
makeCfg(home),
);
@@ -53,16 +43,8 @@ describe("trigger handling", () => {
it("handles inline /whoami and strips it before the agent", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const blockReplies: Array<{ text?: string }> = [];
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk();
const { blockReplies, handlers } = createBlockReplyCollector();
const res = await getReplyFromConfig(
{
Body: "please /whoami now",
@@ -71,11 +53,7 @@ describe("trigger handling", () => {
SenderId: "12345",
CommandAuthorized: true,
},
{
onBlockReply: async (payload) => {
blockReplies.push(payload);
},
},
handlers,
makeCfg(home),
);

View File

@@ -6,6 +6,7 @@ import {
installTriggerHandlingE2eTestHooks,
MAIN_SESSION_KEY,
makeCfg,
makeWhatsAppElevatedCfg,
withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js";
@@ -26,25 +27,7 @@ describe("trigger handling", () => {
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
const cfg = makeWhatsAppElevatedCfg(home);
const res = await getReplyFromConfig(
{

View File

@@ -16,21 +16,46 @@ beforeAll(async () => {
installTriggerHandlingE2eTestHooks();
const BASE_MESSAGE = {
Body: "hello",
From: "+1002",
To: "+2000",
} as const;
function mockEmbeddedOkPayload() {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
return runEmbeddedPiAgentMock;
}
async function writeStoredModelOverride(cfg: ReturnType<typeof makeCfg>): Promise<void> {
await fs.writeFile(
cfg.session.store,
JSON.stringify({
[MAIN_SESSION_KEY]: {
sessionId: "main",
updatedAt: Date.now(),
providerOverride: "openai",
modelOverride: "gpt-5.2",
},
}),
"utf-8",
);
}
describe("trigger handling", () => {
it("includes the error cause when the embedded agent throws", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockRejectedValue(new Error("sandbox is not defined."));
const res = await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home));
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe(
@@ -42,28 +67,9 @@ describe("trigger handling", () => {
it("uses heartbeat model override for heartbeat runs", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const runEmbeddedPiAgentMock = mockEmbeddedOkPayload();
const cfg = makeCfg(home);
await fs.writeFile(
cfg.session.store,
JSON.stringify({
[MAIN_SESSION_KEY]: {
sessionId: "main",
updatedAt: Date.now(),
providerOverride: "openai",
modelOverride: "gpt-5.2",
},
}),
"utf-8",
);
await writeStoredModelOverride(cfg);
cfg.agents = {
...cfg.agents,
defaults: {
@@ -72,15 +78,7 @@ describe("trigger handling", () => {
},
};
await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{ isHeartbeat: true },
cfg,
);
await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg);
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
expect(call?.provider).toBe("anthropic");
@@ -90,38 +88,10 @@ describe("trigger handling", () => {
it("keeps stored model override for heartbeat runs when heartbeat model is not configured", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const runEmbeddedPiAgentMock = mockEmbeddedOkPayload();
const cfg = makeCfg(home);
await fs.writeFile(
cfg.session.store,
JSON.stringify({
[MAIN_SESSION_KEY]: {
sessionId: "main",
updatedAt: Date.now(),
providerOverride: "openai",
modelOverride: "gpt-5.2",
},
}),
"utf-8",
);
await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{ isHeartbeat: true },
cfg,
);
await writeStoredModelOverride(cfg);
await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg);
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
expect(call?.provider).toBe("openai");
@@ -140,15 +110,7 @@ describe("trigger handling", () => {
},
});
const res = await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home));
expect(res).toBeUndefined();
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
@@ -166,15 +128,7 @@ describe("trigger handling", () => {
},
});
const res = await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home));
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("hello");

View File

@@ -15,40 +15,60 @@ beforeAll(async () => {
installTriggerHandlingE2eTestHooks();
function mockEmbeddedOk() {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
return runEmbeddedPiAgentMock;
}
function makeUnauthorizedWhatsAppCfg(home: string) {
const baseCfg = makeCfg(home);
return {
...baseCfg,
channels: {
...baseCfg.channels,
whatsapp: {
allowFrom: ["+1000"],
},
},
};
}
async function runInlineUnauthorizedCommand(params: {
home: string;
command: "/status" | "/help";
getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
}) {
const cfg = makeUnauthorizedWhatsAppCfg(params.home);
const res = await params.getReplyFromConfig(
{
Body: `please ${params.command} now`,
From: "+2001",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+2001",
},
{},
cfg,
);
return { cfg, res };
}
describe("trigger handling", () => {
it("keeps inline /status for unauthorized senders", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
const runEmbeddedPiAgentMock = mockEmbeddedOk();
const { res } = await runInlineUnauthorizedCommand({
home,
command: "/status",
getReplyFromConfig,
});
const baseCfg = makeCfg(home);
const cfg = {
...baseCfg,
channels: {
...baseCfg.channels,
whatsapp: {
allowFrom: ["+1000"],
},
},
};
const res = await getReplyFromConfig(
{
Body: "please /status now",
From: "+2001",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+2001",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgentMock).toHaveBeenCalled();
@@ -60,37 +80,12 @@ describe("trigger handling", () => {
it("keeps inline /help for unauthorized senders", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
const runEmbeddedPiAgentMock = mockEmbeddedOk();
const { res } = await runInlineUnauthorizedCommand({
home,
command: "/help",
getReplyFromConfig,
});
const baseCfg = makeCfg(home);
const cfg = {
...baseCfg,
channels: {
...baseCfg.channels,
whatsapp: {
allowFrom: ["+1000"],
},
},
};
const res = await getReplyFromConfig(
{
Body: "please /help now",
From: "+2001",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+2001",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgentMock).toHaveBeenCalled();

View File

@@ -3,9 +3,11 @@ import { join } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { resolveSessionKey } from "../config/sessions.js";
import {
createBlockReplyCollector,
getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks,
makeCfg,
mockRunEmbeddedPiAgentOk,
withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js";
@@ -85,16 +87,8 @@ describe("trigger handling", () => {
it("strips inline /status and still runs the agent", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const blockReplies: Array<{ text?: string }> = [];
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk();
const { blockReplies, handlers } = createBlockReplyCollector();
await getReplyFromConfig(
{
Body: "please /status now",
@@ -105,11 +99,7 @@ describe("trigger handling", () => {
SenderE164: "+1002",
CommandAuthorized: true,
},
{
onBlockReply: async (payload) => {
blockReplies.push(payload);
},
},
handlers,
makeCfg(home),
);
expect(runEmbeddedPiAgentMock).toHaveBeenCalled();
@@ -124,15 +114,8 @@ describe("trigger handling", () => {
it("handles inline /help and strips it before the agent", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const blockReplies: Array<{ text?: string }> = [];
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk();
const { blockReplies, handlers } = createBlockReplyCollector();
const res = await getReplyFromConfig(
{
Body: "please /help now",
@@ -140,11 +123,7 @@ describe("trigger handling", () => {
To: "+2000",
CommandAuthorized: true,
},
{
onBlockReply: async (payload) => {
blockReplies.push(payload);
},
},
handlers,
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;

View File

@@ -15,6 +15,41 @@ beforeAll(async () => {
installTriggerHandlingE2eTestHooks();
async function expectResetBlockedForNonOwner(params: {
home: string;
commandAuthorized: boolean;
getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
}): Promise<void> {
const { home, commandAuthorized, getReplyFromConfig } = params;
const res = await getReplyFromConfig(
{
Body: "/reset",
From: "+1003",
To: "+2000",
CommandAuthorized: commandAuthorized,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
},
},
channels: {
whatsapp: {
allowFrom: ["+1999"],
},
},
session: {
store: join(tmpdir(), `openclaw-session-test-${Date.now()}.json`),
},
},
);
expect(res).toBeUndefined();
expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled();
}
describe("trigger handling", () => {
it("runs a greeting prompt for a bare /reset", async () => {
await withTempHome(async (home) => {
@@ -23,64 +58,20 @@ describe("trigger handling", () => {
});
it("does not reset for unauthorized /reset", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/reset",
From: "+1003",
To: "+2000",
CommandAuthorized: false,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
},
},
channels: {
whatsapp: {
allowFrom: ["+1999"],
},
},
session: {
store: join(tmpdir(), `openclaw-session-test-${Date.now()}.json`),
},
},
);
expect(res).toBeUndefined();
expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled();
await expectResetBlockedForNonOwner({
home,
commandAuthorized: false,
getReplyFromConfig,
});
});
});
it("blocks /reset for non-owner senders", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/reset",
From: "+1003",
To: "+2000",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
},
},
channels: {
whatsapp: {
allowFrom: ["+1999"],
},
},
session: {
store: join(tmpdir(), `openclaw-session-test-${Date.now()}.json`),
},
},
);
expect(res).toBeUndefined();
expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled();
await expectResetBlockedForNonOwner({
home,
commandAuthorized: true,
getReplyFromConfig,
});
});
});
});

View File

@@ -14,24 +14,22 @@ beforeAll(async () => {
installTriggerHandlingE2eTestHooks();
const modelStatusCtx = {
Body: "/model status",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: "telegram:slash:111",
CommandAuthorized: true,
} as const;
describe("trigger handling", () => {
it("shows endpoint default in /model status when not configured", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
{
Body: "/model status",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: "telegram:slash:111",
CommandAuthorized: true,
},
{},
cfg,
);
const res = await getReplyFromConfig(modelStatusCtx, {}, cfg);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain("endpoint: default");
@@ -50,20 +48,7 @@ describe("trigger handling", () => {
},
},
};
const res = await getReplyFromConfig(
{
Body: "/model status",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: "telegram:slash:111",
CommandAuthorized: true,
},
{},
cfg,
);
const res = await getReplyFromConfig(modelStatusCtx, {}, cfg);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");

View File

@@ -1,79 +0,0 @@
import fs from "node:fs/promises";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { MsgContext, TemplateContext } from "../templating.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
const sandboxMocks = vi.hoisted(() => ({
ensureSandboxWorkspaceForSession: vi.fn(),
}));
vi.mock("../agents/sandbox.js", () => sandboxMocks);
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(async (home) => await fn(home), { prefix: "openclaw-triggers-bypass-" });
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("stageSandboxMedia security", () => {
it("rejects staging host files from outside the media directory", async () => {
await withTempHome(async (home) => {
// Sensitive host file outside .openclaw
const sensitiveFile = join(home, "secrets.txt");
await fs.writeFile(sensitiveFile, "SENSITIVE DATA");
const sandboxDir = join(home, "sandboxes", "session");
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
workspaceDir: sandboxDir,
containerWorkdir: "/work",
});
const ctx: MsgContext = {
Body: "hi",
From: "whatsapp:group:demo",
To: "+2000",
ChatType: "group",
Provider: "whatsapp",
MediaPath: sensitiveFile,
MediaType: "image/jpeg",
MediaUrl: sensitiveFile,
};
const sessionCtx: TemplateContext = { ...ctx };
// This should fail or skip the file
await stageSandboxMedia({
ctx,
sessionCtx,
cfg: {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
sandbox: {
mode: "non-main",
workspaceRoot: join(home, "sandboxes"),
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: join(home, "sessions.json") },
},
sessionKey: "agent:main:main",
workspaceDir: join(home, "openclaw"),
});
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(sensitiveFile));
// Expect the file NOT to be staged
await expect(fs.stat(stagedFullPath)).rejects.toThrow();
// Context should NOT be rewritten to a sandbox path if it failed to stage
expect(ctx.MediaPath).toBe(sensitiveFile);
});
});
});

View File

@@ -1,8 +1,11 @@
import fs from "node:fs/promises";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { MsgContext, TemplateContext } from "./templating.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import {
createSandboxMediaContexts,
createSandboxMediaStageConfig,
withSandboxMediaTempHome,
} from "./stage-sandbox-media.test-harness.js";
const sandboxMocks = vi.hoisted(() => ({
ensureSandboxWorkspaceForSession: vi.fn(),
@@ -13,17 +16,13 @@ vi.mock("../agents/sandbox.js", () => sandboxMocks);
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(async (home) => await fn(home), { prefix: "openclaw-triggers-" });
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("stageSandboxMedia", () => {
it("stages inbound media into the sandbox workspace", async () => {
await withTempHome(async (home) => {
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
const inboundDir = join(home, ".openclaw", "media", "inbound");
await fs.mkdir(inboundDir, { recursive: true });
const mediaPath = join(inboundDir, "photo.jpg");
@@ -35,35 +34,12 @@ describe("stageSandboxMedia", () => {
containerWorkdir: "/work",
});
const ctx: MsgContext = {
Body: "hi",
From: "whatsapp:group:demo",
To: "+2000",
ChatType: "group",
Provider: "whatsapp",
MediaPath: mediaPath,
MediaType: "image/jpeg",
MediaUrl: mediaPath,
};
const sessionCtx: TemplateContext = { ...ctx };
const { ctx, sessionCtx } = createSandboxMediaContexts(mediaPath);
await stageSandboxMedia({
ctx,
sessionCtx,
cfg: {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
sandbox: {
mode: "non-main",
workspaceRoot: join(home, "sandboxes"),
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: join(home, "sessions.json") },
},
cfg: createSandboxMediaStageConfig(home),
sessionKey: "agent:main:main",
workspaceDir: join(home, "openclaw"),
});
@@ -78,4 +54,36 @@ describe("stageSandboxMedia", () => {
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
});
});
it("rejects staging host files from outside the media directory", async () => {
await withSandboxMediaTempHome("openclaw-triggers-bypass-", async (home) => {
// Sensitive host file outside .openclaw
const sensitiveFile = join(home, "secrets.txt");
await fs.writeFile(sensitiveFile, "SENSITIVE DATA");
const sandboxDir = join(home, "sandboxes", "session");
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
workspaceDir: sandboxDir,
containerWorkdir: "/work",
});
const { ctx, sessionCtx } = createSandboxMediaContexts(sensitiveFile);
// This should fail or skip the file
await stageSandboxMedia({
ctx,
sessionCtx,
cfg: createSandboxMediaStageConfig(home),
sessionKey: "agent:main:main",
workspaceDir: join(home, "openclaw"),
});
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(sensitiveFile));
// Expect the file NOT to be staged
await expect(fs.stat(stagedFullPath)).rejects.toThrow();
// Context should NOT be rewritten to a sandbox path if it failed to stage
expect(ctx.MediaPath).toBe(sensitiveFile);
});
});
});

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
@@ -134,6 +135,60 @@ export function makeCfg(home: string): OpenClawConfig {
} as OpenClawConfig;
}
export function makeWhatsAppElevatedCfg(
home: string,
opts?: { elevatedEnabled?: boolean; requireMentionInGroups?: boolean },
): OpenClawConfig {
const cfg = makeCfg(home);
cfg.channels ??= {};
cfg.channels.whatsapp = {
...cfg.channels.whatsapp,
allowFrom: ["+1000"],
};
if (opts?.requireMentionInGroups !== undefined) {
cfg.channels.whatsapp.groups = { "*": { requireMention: opts.requireMentionInGroups } };
}
cfg.tools = {
...cfg.tools,
elevated: {
allowFrom: { whatsapp: ["+1000"] },
...(opts?.elevatedEnabled === false ? { enabled: false } : {}),
},
};
return cfg;
}
export async function runDirectElevatedToggleAndLoadStore(params: {
cfg: OpenClawConfig;
getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
body?: string;
}): Promise<{
text: string | undefined;
store: Record<string, { elevatedLevel?: string }>;
}> {
const res = await params.getReplyFromConfig(
{
Body: params.body ?? "/elevated on",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
{},
params.cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const storePath = params.cfg.session?.store;
if (!storePath) {
throw new Error("session.store is required in test config");
}
const storeRaw = await fs.readFile(storePath, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
return { text, store };
}
export async function runGreetingPromptForBareNewOrReset(params: {
home: string;
body: "/new" | "/reset";
@@ -169,3 +224,27 @@ export function installTriggerHandlingE2eTestHooks() {
vi.restoreAllMocks();
});
}
export function mockRunEmbeddedPiAgentOk(text = "ok"): AnyMock {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
return runEmbeddedPiAgentMock;
}
export function createBlockReplyCollector() {
const blockReplies: Array<{ text?: string }> = [];
return {
blockReplies,
handlers: {
onBlockReply: async (payload: { text?: string }) => {
blockReplies.push(payload);
},
},
};
}

View File

@@ -38,6 +38,27 @@ vi.mock("../../agents/subagent-registry.js", () => ({
}));
describe("abort detection", () => {
async function runStopCommand(params: {
cfg: OpenClawConfig;
sessionKey: string;
from: string;
to: string;
}) {
return tryFastAbortFromMessage({
ctx: buildTestCtx({
CommandBody: "/stop",
RawBody: "/stop",
CommandAuthorized: true,
SessionKey: params.sessionKey,
Provider: "telegram",
Surface: "telegram",
From: params.from,
To: params.to,
}),
cfg: params.cfg,
});
}
afterEach(() => {
resetAbortMemoryForTest();
});
@@ -109,18 +130,11 @@ describe("abort detection", () => {
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath }, commands: { text: false } } as OpenClawConfig;
const result = await tryFastAbortFromMessage({
ctx: buildTestCtx({
CommandBody: "/stop",
RawBody: "/stop",
CommandAuthorized: true,
SessionKey: "telegram:123",
Provider: "telegram",
Surface: "telegram",
From: "telegram:123",
To: "telegram:123",
}),
const result = await runStopCommand({
cfg,
sessionKey: "telegram:123",
from: "telegram:123",
to: "telegram:123",
});
expect(result.handled).toBe(true);
@@ -172,18 +186,11 @@ describe("abort detection", () => {
);
expect(getFollowupQueueDepth(sessionKey)).toBe(1);
const result = await tryFastAbortFromMessage({
ctx: buildTestCtx({
CommandBody: "/stop",
RawBody: "/stop",
CommandAuthorized: true,
SessionKey: sessionKey,
Provider: "telegram",
Surface: "telegram",
From: "telegram:123",
To: "telegram:123",
}),
const result = await runStopCommand({
cfg,
sessionKey,
from: "telegram:123",
to: "telegram:123",
});
expect(result.handled).toBe(true);
@@ -229,18 +236,11 @@ describe("abort detection", () => {
},
]);
const result = await tryFastAbortFromMessage({
ctx: buildTestCtx({
CommandBody: "/stop",
RawBody: "/stop",
CommandAuthorized: true,
SessionKey: sessionKey,
Provider: "telegram",
Surface: "telegram",
From: "telegram:parent",
To: "telegram:parent",
}),
const result = await runStopCommand({
cfg,
sessionKey,
from: "telegram:parent",
to: "telegram:parent",
});
expect(result.stoppedSubagents).toBe(1);
@@ -307,18 +307,11 @@ describe("abort detection", () => {
])
.mockReturnValueOnce([]);
const result = await tryFastAbortFromMessage({
ctx: buildTestCtx({
CommandBody: "/stop",
RawBody: "/stop",
CommandAuthorized: true,
SessionKey: sessionKey,
Provider: "telegram",
Surface: "telegram",
From: "telegram:parent",
To: "telegram:parent",
}),
const result = await runStopCommand({
cfg,
sessionKey,
from: "telegram:parent",
to: "telegram:parent",
});
// Should stop both depth-1 and depth-2 agents (cascade)
@@ -389,18 +382,11 @@ describe("abort detection", () => {
])
.mockReturnValueOnce([]);
const result = await tryFastAbortFromMessage({
ctx: buildTestCtx({
CommandBody: "/stop",
RawBody: "/stop",
CommandAuthorized: true,
SessionKey: sessionKey,
Provider: "telegram",
Surface: "telegram",
From: "telegram:parent",
To: "telegram:parent",
}),
const result = await runStopCommand({
cfg,
sessionKey,
from: "telegram:parent",
to: "telegram:parent",
});
// Should skip killing the ended depth-1 run itself, but still kill depth-2.

View File

@@ -11,28 +11,18 @@ import { createOpenClawTools } from "../../agents/openclaw-tools.js";
import { getChannelDock } from "../../channels/dock.js";
import { logVerbose } from "../../globals.js";
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
import { listChatCommands } from "../commands-registry.js";
import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js";
import {
listReservedChatSlashCommandNames,
listSkillCommandsForWorkspace,
resolveSkillCommandInvocation,
} from "../skill-commands.js";
import { getAbortMemory } from "./abort.js";
import { buildStatusReply, handleCommands } from "./commands.js";
import { isDirectiveOnly } from "./directive-handling.js";
import { extractInlineSimpleCommand } from "./reply-inline.js";
const builtinSlashCommands = (() => {
const reserved = new Set<string>();
for (const command of listChatCommands()) {
if (command.nativeName) {
reserved.add(command.nativeName.toLowerCase());
}
for (const alias of command.textAliases) {
const trimmed = alias.trim();
if (!trimmed.startsWith("/")) {
continue;
}
reserved.add(trimmed.slice(1).toLowerCase());
}
}
for (const name of [
return listReservedChatSlashCommandNames([
"think",
"verbose",
"reasoning",
@@ -41,10 +31,7 @@ const builtinSlashCommands = (() => {
"model",
"status",
"queue",
]) {
reserved.add(name);
}
return reserved;
]);
})();
function resolveSlashCommandName(commandBodyNormalized: string): string | null {

View File

@@ -44,6 +44,30 @@ describe("createModelSelectionState parent inheritance", () => {
});
}
async function resolveHeartbeatStoredOverrideState(hasResolvedHeartbeatModelOverride: boolean) {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:discord:channel:c1";
const sessionEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
const sessionStore = { [sessionKey]: sessionEntry };
return createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: "anthropic",
model: "claude-opus-4-5",
hasModelDirective: false,
hasResolvedHeartbeatModelOverride,
});
}
it("inherits parent override from explicit parentSessionKey", async () => {
const cfg = {} as OpenClawConfig;
const parentKey = "agent:main:discord:channel:c1";
@@ -157,58 +181,14 @@ describe("createModelSelectionState parent inheritance", () => {
});
it("applies stored override when heartbeat override was not resolved", async () => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:discord:channel:c1";
const sessionEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
const sessionStore = {
[sessionKey]: sessionEntry,
};
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: "anthropic",
model: "claude-opus-4-5",
hasModelDirective: false,
hasResolvedHeartbeatModelOverride: false,
});
const state = await resolveHeartbeatStoredOverrideState(false);
expect(state.provider).toBe("openai");
expect(state.model).toBe("gpt-4o");
});
it("skips stored override when heartbeat override was resolved", async () => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:discord:channel:c1";
const sessionEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
const sessionStore = {
[sessionKey]: sessionEntry,
};
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: "anthropic",
model: "claude-opus-4-5",
hasModelDirective: false,
hasResolvedHeartbeatModelOverride: true,
});
const state = await resolveHeartbeatStoredOverrideState(true);
expect(state.provider).toBe("anthropic");
expect(state.model).toBe("claude-opus-4-5");
@@ -219,16 +199,12 @@ describe("createModelSelectionState respects session model override", () => {
const defaultProvider = "inferencer";
const defaultModel = "deepseek-v3-4bit-mlx";
it("applies session modelOverride when set", async () => {
async function resolveState(sessionEntry: ReturnType<typeof makeEntry>) {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:main";
const sessionEntry = makeEntry({
providerOverride: "kimi-coding",
modelOverride: "k2p5",
});
const sessionStore = { [sessionKey]: sessionEntry };
const state = await createModelSelectionState({
return createModelSelectionState({
cfg,
agentCfg: undefined,
sessionEntry,
@@ -240,29 +216,22 @@ describe("createModelSelectionState respects session model override", () => {
model: defaultModel,
hasModelDirective: false,
});
}
it("applies session modelOverride when set", async () => {
const state = await resolveState(
makeEntry({
providerOverride: "kimi-coding",
modelOverride: "k2p5",
}),
);
expect(state.provider).toBe("kimi-coding");
expect(state.model).toBe("k2p5");
});
it("falls back to default when no modelOverride is set", async () => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:main";
const sessionEntry = makeEntry();
const sessionStore = { [sessionKey]: sessionEntry };
const state = await createModelSelectionState({
cfg,
agentCfg: undefined,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: defaultProvider,
model: defaultModel,
hasModelDirective: false,
});
const state = await resolveState(makeEntry());
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe(defaultModel);
@@ -270,54 +239,26 @@ describe("createModelSelectionState respects session model override", () => {
it("respects modelOverride even when session model field differs", async () => {
// From issue #14783: stored override should beat last-used fallback model.
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:main";
const sessionEntry = makeEntry({
model: "k2p5",
modelProvider: "kimi-coding",
contextTokens: 262_000,
providerOverride: "anthropic",
modelOverride: "claude-opus-4-5",
});
const sessionStore = { [sessionKey]: sessionEntry };
const state = await createModelSelectionState({
cfg,
agentCfg: undefined,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: defaultProvider,
model: defaultModel,
hasModelDirective: false,
});
const state = await resolveState(
makeEntry({
model: "k2p5",
modelProvider: "kimi-coding",
contextTokens: 262_000,
providerOverride: "anthropic",
modelOverride: "claude-opus-4-5",
}),
);
expect(state.provider).toBe("anthropic");
expect(state.model).toBe("claude-opus-4-5");
});
it("uses default provider when providerOverride is not set but modelOverride is", async () => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:main";
const sessionEntry = makeEntry({
modelOverride: "deepseek-v3-4bit-mlx",
});
const sessionStore = { [sessionKey]: sessionEntry };
const state = await createModelSelectionState({
cfg,
agentCfg: undefined,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: defaultProvider,
model: defaultModel,
hasModelDirective: false,
});
const state = await resolveState(
makeEntry({
modelOverride: "deepseek-v3-4bit-mlx",
}),
);
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe("deepseek-v3-4bit-mlx");

View File

@@ -5,7 +5,7 @@ import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agent
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { listChatCommands } from "./commands-registry.js";
function resolveReservedCommandNames(): Set<string> {
export function listReservedChatSlashCommandNames(extraNames: string[] = []): Set<string> {
const reserved = new Set<string>();
for (const command of listChatCommands()) {
if (command.nativeName) {
@@ -19,6 +19,12 @@ function resolveReservedCommandNames(): Set<string> {
reserved.add(trimmed.slice(1).toLowerCase());
}
}
for (const name of extraNames) {
const trimmed = name.trim().toLowerCase();
if (trimmed) {
reserved.add(trimmed);
}
}
return reserved;
}
@@ -31,7 +37,7 @@ export function listSkillCommandsForWorkspace(params: {
config: params.cfg,
skillFilter: params.skillFilter,
eligibility: { remote: getRemoteSkillEligibility() },
reservedNames: resolveReservedCommandNames(),
reservedNames: listReservedChatSlashCommandNames(),
});
}
@@ -39,7 +45,7 @@ export function listSkillCommandsForAgents(params: {
cfg: OpenClawConfig;
agentIds?: string[];
}): SkillCommandSpec[] {
const used = resolveReservedCommandNames();
const used = listReservedChatSlashCommandNames();
const entries: SkillCommandSpec[] = [];
const agentIds = params.agentIds ?? listAgentIds(params.cfg);
// Track visited workspace dirs to avoid registering duplicate commands

View File

@@ -0,0 +1,45 @@
import { join } from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import type { MsgContext, TemplateContext } from "./templating.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
export async function withSandboxMediaTempHome<T>(
prefix: string,
fn: (home: string) => Promise<T>,
): Promise<T> {
return withTempHomeBase(async (home) => await fn(home), { prefix });
}
export function createSandboxMediaContexts(mediaPath: string): {
ctx: MsgContext;
sessionCtx: TemplateContext;
} {
const ctx: MsgContext = {
Body: "hi",
From: "whatsapp:group:demo",
To: "+2000",
ChatType: "group",
Provider: "whatsapp",
MediaPath: mediaPath,
MediaType: "image/jpeg",
MediaUrl: mediaPath,
};
return { ctx, sessionCtx: { ...ctx } };
}
export function createSandboxMediaStageConfig(home: string): OpenClawConfig {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "openclaw"),
sandbox: {
mode: "non-main",
workspaceRoot: join(home, "sandboxes"),
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: join(home, "sessions.json") },
} as OpenClawConfig;
}

View File

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js";
import {
buildCommandsMessage,
buildCommandsMessagePaginated,
@@ -129,29 +130,7 @@ describe("buildStatusMessage", () => {
sessionKey: "agent:main:main",
queue: { mode: "none" },
mediaDecisions: [
{
capability: "image",
outcome: "success",
attachments: [
{
attachmentIndex: 0,
attempts: [
{
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
],
chosen: {
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
},
],
},
createSuccessfulImageMediaDecision(),
{
capability: "audio",
outcome: "skipped",
@@ -382,39 +361,58 @@ describe("buildStatusMessage", () => {
);
}
const baselineTranscriptUsage = {
input: 1,
output: 2,
cacheRead: 1000,
cacheWrite: 0,
totalTokens: 1003,
} as const;
function writeBaselineTranscriptUsageLog(params: {
dir: string;
agentId: string;
sessionId: string;
}) {
writeTranscriptUsageLog({
...params,
usage: baselineTranscriptUsage,
});
}
function buildTranscriptStatusText(params: { sessionId: string; sessionKey: string }) {
return buildStatusMessage({
agent: {
model: "anthropic/claude-opus-4-5",
contextTokens: 32_000,
},
sessionEntry: {
sessionId: params.sessionId,
updatedAt: 0,
totalTokens: 3,
contextTokens: 32_000,
},
sessionKey: params.sessionKey,
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
});
}
it("prefers cached prompt tokens from the session log", async () => {
await withTempHome(
async (dir) => {
const sessionId = "sess-1";
writeTranscriptUsageLog({
writeBaselineTranscriptUsageLog({
dir,
agentId: "main",
sessionId,
usage: {
input: 1,
output: 2,
cacheRead: 1000,
cacheWrite: 0,
totalTokens: 1003,
},
});
const text = buildStatusMessage({
agent: {
model: "anthropic/claude-opus-4-5",
contextTokens: 32_000,
},
sessionEntry: {
sessionId,
updatedAt: 0,
totalTokens: 3, // would be wrong if cached prompt tokens exist
contextTokens: 32_000,
},
const text = buildTranscriptStatusText({
sessionId,
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
});
expect(normalizeTestText(text)).toContain("Context: 1.0k/32k");
@@ -427,35 +425,15 @@ describe("buildStatusMessage", () => {
await withTempHome(
async (dir) => {
const sessionId = "sess-worker1";
writeTranscriptUsageLog({
writeBaselineTranscriptUsageLog({
dir,
agentId: "worker1",
sessionId,
usage: {
input: 1,
output: 2,
cacheRead: 1000,
cacheWrite: 0,
totalTokens: 1003,
},
});
const text = buildStatusMessage({
agent: {
model: "anthropic/claude-opus-4-5",
contextTokens: 32_000,
},
sessionEntry: {
sessionId,
updatedAt: 0,
totalTokens: 3,
contextTokens: 32_000,
},
const text = buildTranscriptStatusText({
sessionId,
sessionKey: "agent:worker1:telegram:12345",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
});
expect(normalizeTestText(text)).toContain("Context: 1.0k/32k");