mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 01:31:23 +00:00
refactor(agent): dedupe harness and command workflows
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
|
||||
25
src/auto-reply/media-understanding.test-fixtures.ts
Normal file
25
src/auto-reply/media-understanding.test-fixtures.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } = {},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
45
src/auto-reply/stage-sandbox-media.test-harness.ts
Normal file
45
src/auto-reply/stage-sandbox-media.test-harness.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user