test: consolidate auto-reply unit coverage
This commit is contained in:
@@ -1,122 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveCommandAuthorization } from "./command-auth.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
|
||||
describe("resolveCommandAuthorization", () => {
|
||||
it("falls back from empty SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: "",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back from whitespace SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: " ",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+999"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: " ",
|
||||
SenderE164: " ",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+999");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back from un-normalizable SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: "wat",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:120363401234567890@g.us",
|
||||
SenderId: "123@lid",
|
||||
SenderE164: "+41796666864",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+41796666864");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { resolveCommandAuthorization } from "./command-auth.js";
|
||||
import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js";
|
||||
import { listChatCommands } from "./commands-registry.js";
|
||||
import { parseActivationCommand } from "./group-activation.js";
|
||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
@@ -14,6 +18,123 @@ afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
describe("resolveCommandAuthorization", () => {
|
||||
it("falls back from empty SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: "",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back from whitespace SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: " ",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+999"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: " ",
|
||||
SenderE164: " ",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+999");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back from un-normalizable SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: "wat",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:120363401234567890@g.us",
|
||||
SenderId: "123@lid",
|
||||
SenderE164: "+41796666864",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+41796666864");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("control command parsing", () => {
|
||||
it("requires slash for send policy", () => {
|
||||
expect(parseSendPolicyCommand("/send on")).toEqual({
|
||||
@@ -1,156 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
serializeCommandArgs,
|
||||
} from "./commands-registry.js";
|
||||
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
||||
|
||||
describe("commands registry args", () => {
|
||||
it("parses positional args and captureRemaining", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "debug",
|
||||
description: "debug",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{ name: "action", description: "action", type: "string" },
|
||||
{ name: "path", description: "path", type: "string" },
|
||||
{ name: "value", description: "value", type: "string", captureRemaining: true },
|
||||
],
|
||||
};
|
||||
|
||||
const args = parseCommandArgs(command, "set foo bar baz");
|
||||
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
|
||||
});
|
||||
|
||||
it("serializes args via raw first, then values", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
description: "model",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
|
||||
};
|
||||
|
||||
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
|
||||
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"gpt-5.2-codex",
|
||||
);
|
||||
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"/model gpt-5.2-codex",
|
||||
);
|
||||
});
|
||||
|
||||
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 menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("mode");
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
||||
});
|
||||
|
||||
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 menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { values: { mode: "tokens" } },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves function-based choices with a default provider/model context", () => {
|
||||
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
|
||||
null;
|
||||
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "think",
|
||||
description: "think",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "level",
|
||||
description: "level",
|
||||
type: "string",
|
||||
choices: ({ provider, model, command, arg }) => {
|
||||
seen = { provider, model, commandKey: command.key, argName: arg.name };
|
||||
return ["low", "high"];
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("level");
|
||||
expect(menu?.choices).toEqual(["low", "high"]);
|
||||
expect(seen?.commandKey).toBe("think");
|
||||
expect(seen?.argName).toBe("level");
|
||||
expect(seen?.provider).toBeTruthy();
|
||||
expect(seen?.model).toBeTruthy();
|
||||
});
|
||||
|
||||
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 menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { raw: "on" },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCommandText,
|
||||
buildCommandTextFromArgs,
|
||||
getCommandDetection,
|
||||
listChatCommands,
|
||||
listChatCommandsForConfig,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
normalizeCommandBody,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
serializeCommandArgs,
|
||||
shouldHandleTextCommands,
|
||||
} from "./commands-registry.js";
|
||||
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
@@ -154,3 +159,150 @@ describe("commands registry", () => {
|
||||
expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram");
|
||||
});
|
||||
});
|
||||
|
||||
describe("commands registry args", () => {
|
||||
it("parses positional args and captureRemaining", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "debug",
|
||||
description: "debug",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{ name: "action", description: "action", type: "string" },
|
||||
{ name: "path", description: "path", type: "string" },
|
||||
{ name: "value", description: "value", type: "string", captureRemaining: true },
|
||||
],
|
||||
};
|
||||
|
||||
const args = parseCommandArgs(command, "set foo bar baz");
|
||||
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
|
||||
});
|
||||
|
||||
it("serializes args via raw first, then values", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
description: "model",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
|
||||
};
|
||||
|
||||
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
|
||||
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"gpt-5.2-codex",
|
||||
);
|
||||
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"/model gpt-5.2-codex",
|
||||
);
|
||||
});
|
||||
|
||||
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 menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("mode");
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
||||
});
|
||||
|
||||
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 menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { values: { mode: "tokens" } },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves function-based choices with a default provider/model context", () => {
|
||||
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
|
||||
null;
|
||||
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "think",
|
||||
description: "think",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "level",
|
||||
description: "level",
|
||||
type: "string",
|
||||
choices: ({ provider, model, command, arg }) => {
|
||||
seen = { provider, model, commandKey: command.key, argName: arg.name };
|
||||
return ["low", "high"];
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("level");
|
||||
expect(menu?.choices).toEqual(["low", "high"]);
|
||||
expect(seen?.commandKey).toBe("think");
|
||||
expect(seen?.argName).toBe("level");
|
||||
expect(seen?.provider).toBeTruthy();
|
||||
expect(seen?.model).toBeTruthy();
|
||||
});
|
||||
|
||||
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 menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { raw: "on" },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createInboundDebouncer } from "./inbound-debounce.js";
|
||||
|
||||
describe("createInboundDebouncer", () => {
|
||||
it("debounces and combines items", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
||||
debounceMs: 10,
|
||||
buildKey: (item) => item.key,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1" });
|
||||
await debouncer.enqueue({ key: "a", id: "2" });
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(calls).toEqual([["1", "2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("flushes buffered items before non-debounced item", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
||||
debounceMs: 50,
|
||||
buildKey: (item) => item.key,
|
||||
shouldDebounce: (item) => item.debounce,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
||||
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
||||
|
||||
expect(calls).toEqual([["1"], ["2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
402
src/auto-reply/inbound.test.ts
Normal file
402
src/auto-reply/inbound.test.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { GroupKeyResolution } from "../config/sessions.js";
|
||||
import { createInboundDebouncer } from "./inbound-debounce.js";
|
||||
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";
|
||||
import { finalizeInboundContext } from "./reply/inbound-context.js";
|
||||
import {
|
||||
buildInboundDedupeKey,
|
||||
resetInboundDedupe,
|
||||
shouldSkipDuplicateInbound,
|
||||
} from "./reply/inbound-dedupe.js";
|
||||
import { formatInboundBodyWithSenderMeta } from "./reply/inbound-sender-meta.js";
|
||||
import { normalizeInboundTextNewlines } from "./reply/inbound-text.js";
|
||||
import { resolveGroupRequireMention } from "./reply/groups.js";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
normalizeMentionText,
|
||||
} from "./reply/mentions.js";
|
||||
import { initSessionState } from "./reply/session.js";
|
||||
|
||||
describe("applyTemplate", () => {
|
||||
it("renders primitive values", () => {
|
||||
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
|
||||
const overrides = ctx as Record<string, unknown>;
|
||||
overrides.MessageSid = 42;
|
||||
overrides.IsNewSession = true;
|
||||
|
||||
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
|
||||
});
|
||||
|
||||
it("renders arrays of primitives", () => {
|
||||
const ctx = { MediaPaths: ["a"] } as TemplateContext;
|
||||
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
|
||||
|
||||
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
|
||||
});
|
||||
|
||||
it("drops object values", () => {
|
||||
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
|
||||
|
||||
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
|
||||
});
|
||||
|
||||
it("renders missing placeholders as empty", () => {
|
||||
const ctx: TemplateContext = {};
|
||||
|
||||
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInboundTextNewlines", () => {
|
||||
it("keeps real newlines", () => {
|
||||
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("normalizes CRLF/CR to LF", () => {
|
||||
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
|
||||
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("decodes literal \\n to newlines when no real newlines exist", () => {
|
||||
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("finalizeInboundContext", () => {
|
||||
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
|
||||
const ctx: MsgContext = {
|
||||
Body: "a\\nb\r\nc",
|
||||
RawBody: "raw\\nline",
|
||||
ChatType: "channel",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
GroupSubject: "Test",
|
||||
};
|
||||
|
||||
const out = finalizeInboundContext(ctx);
|
||||
expect(out.Body).toBe("a\nb\nc");
|
||||
expect(out.RawBody).toBe("raw\nline");
|
||||
expect(out.BodyForAgent).toBe("a\nb\nc");
|
||||
expect(out.BodyForCommands).toBe("raw\nline");
|
||||
expect(out.CommandAuthorized).toBe(false);
|
||||
expect(out.ChatType).toBe("channel");
|
||||
expect(out.ConversationLabel).toContain("Test");
|
||||
});
|
||||
|
||||
it("can force BodyForCommands to follow updated CommandBody", () => {
|
||||
const ctx: MsgContext = {
|
||||
Body: "base",
|
||||
BodyForCommands: "<media:audio>",
|
||||
CommandBody: "say hi",
|
||||
From: "signal:+15550001111",
|
||||
ChatType: "direct",
|
||||
};
|
||||
|
||||
finalizeInboundContext(ctx, { forceBodyForCommands: true });
|
||||
expect(ctx.BodyForCommands).toBe("say hi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInboundBodyWithSenderMeta", () => {
|
||||
it("does nothing for direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
|
||||
});
|
||||
|
||||
it("appends a sender meta line for non-direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers SenderE164 in the label when present", () => {
|
||||
const ctx: MsgContext = {
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderId: "bob@s.whatsapp.net",
|
||||
SenderE164: "+222",
|
||||
};
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||
"[X] hi\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends with a real newline even if the body contains literal \\n", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
|
||||
"[X] one\\n[X] two\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate a sender meta line when one is already present", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not append when the body already includes a sender prefix", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
|
||||
});
|
||||
|
||||
it("does not append when the sender prefix follows an envelope header", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
|
||||
"[Signal Group] Alice (A1): hi",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inbound dedupe", () => {
|
||||
it("builds a stable key when MessageSid is present", () => {
|
||||
const ctx: MsgContext = {
|
||||
Provider: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:123",
|
||||
MessageSid: "42",
|
||||
};
|
||||
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
|
||||
});
|
||||
|
||||
it("skips duplicates with the same key", () => {
|
||||
resetInboundDedupe();
|
||||
const ctx: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+1555",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
|
||||
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not dedupe when the peer changes", () => {
|
||||
resetInboundDedupe();
|
||||
const base: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not dedupe across session keys", () => {
|
||||
resetInboundDedupe();
|
||||
const base: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+1555",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInboundDebouncer", () => {
|
||||
it("debounces and combines items", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
||||
debounceMs: 10,
|
||||
buildKey: (item) => item.key,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1" });
|
||||
await debouncer.enqueue({ key: "a", id: "2" });
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(calls).toEqual([["1", "2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("flushes buffered items before non-debounced item", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
||||
debounceMs: 50,
|
||||
buildKey: (item) => item.key,
|
||||
shouldDebounce: (item) => item.debounce,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
||||
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
||||
|
||||
expect(calls).toEqual([["1"], ["2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSessionState sender meta", () => {
|
||||
it("injects sender meta into BodyStripped for group chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp 123@g.us] ping",
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SenderId: "222@s.whatsapp.net",
|
||||
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("does not inject sender meta for direct chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp +1] ping",
|
||||
ChatType: "direct",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SessionKey: "agent:main:whatsapp:dm:+222",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mention helpers", () => {
|
||||
it("builds regexes and skips invalid patterns", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
|
||||
},
|
||||
});
|
||||
expect(regexes).toHaveLength(1);
|
||||
expect(regexes[0]?.test("clawd")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes zero-width characters", () => {
|
||||
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
|
||||
});
|
||||
|
||||
it("matches patterns case-insensitively", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
|
||||
});
|
||||
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses per-agent mention patterns when configured", () => {
|
||||
const regexes = buildMentionRegexes(
|
||||
{
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"work",
|
||||
);
|
||||
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
|
||||
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGroupRequireMention", () => {
|
||||
it("respects Discord guild/channel requireMention settings", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
guilds: {
|
||||
"145": {
|
||||
requireMention: false,
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Provider: "discord",
|
||||
From: "discord:group:123",
|
||||
GroupChannel: "#general",
|
||||
GroupSpace: "145",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
channel: "discord",
|
||||
id: "123",
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
|
||||
it("respects Slack channel requireMention settings", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
channels: {
|
||||
C123: { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Provider: "slack",
|
||||
From: "slack:channel:C123",
|
||||
GroupSubject: "#general",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
channel: "slack",
|
||||
id: "C123",
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,174 +2,79 @@ import fs from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { MsgContext, TemplateContext } from "./templating.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 sandboxMocks = vi.hoisted(() => ({
|
||||
ensureSandboxWorkspaceForSession: vi.fn(),
|
||||
}));
|
||||
|
||||
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("../agents/sandbox.js", () => sandboxMocks);
|
||||
|
||||
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 { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
||||
import { resolveAgentIdFromSessionKey, resolveSessionKey } from "../config/sessions.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);
|
||||
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(
|
||||
async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockClear();
|
||||
vi.mocked(abortEmbeddedPiRun).mockClear();
|
||||
return await fn(home);
|
||||
},
|
||||
{ prefix: "clawdbot-triggers-" },
|
||||
);
|
||||
}
|
||||
|
||||
function _makeCfg(home: string) {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
return withTempHomeBase(async (home) => await fn(home), { prefix: "clawdbot-triggers-" });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("trigger handling", () => {
|
||||
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => {
|
||||
describe("stageSandboxMedia", () => {
|
||||
it("stages inbound media into the sandbox workspace", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const inboundDir = join(home, ".clawdbot", "media", "inbound");
|
||||
await fs.mkdir(inboundDir, { recursive: true });
|
||||
const mediaPath = join(inboundDir, "photo.jpg");
|
||||
await fs.writeFile(mediaPath, "test");
|
||||
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
const sandboxDir = join(home, "sandboxes", "session");
|
||||
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
|
||||
workspaceDir: sandboxDir,
|
||||
containerWorkdir: "/work",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
sandbox: {
|
||||
mode: "non-main" as const,
|
||||
workspaceRoot: join(home, "sandboxes"),
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
session: {
|
||||
store: join(home, "sessions.json"),
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
const ctx: MsgContext = {
|
||||
Body: "hi",
|
||||
From: "whatsapp:group:demo",
|
||||
To: "+2000",
|
||||
ChatType: "group" as const,
|
||||
Provider: "whatsapp" as const,
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
MediaPath: mediaPath,
|
||||
MediaType: "image/jpeg",
|
||||
MediaUrl: mediaPath,
|
||||
};
|
||||
const sessionCtx: TemplateContext = { ...ctx };
|
||||
|
||||
const res = await getReplyFromConfig(ctx, {}, cfg);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("ok");
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||
|
||||
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
||||
const stagedPath = `media/inbound/${basename(mediaPath)}`;
|
||||
expect(prompt).toContain(stagedPath);
|
||||
expect(prompt).not.toContain(mediaPath);
|
||||
|
||||
const sessionKey = resolveSessionKey(
|
||||
cfg.session?.scope ?? "per-sender",
|
||||
await stageSandboxMedia({
|
||||
ctx,
|
||||
cfg.session?.mainKey,
|
||||
);
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const sandbox = await ensureSandboxWorkspaceForSession({
|
||||
config: cfg,
|
||||
sessionKey,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
sessionCtx,
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
sandbox: {
|
||||
mode: "non-main",
|
||||
workspaceRoot: join(home, "sandboxes"),
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: join(home, "sessions.json") },
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: join(home, "clawd"),
|
||||
});
|
||||
expect(sandbox).not.toBeNull();
|
||||
if (!sandbox) {
|
||||
throw new Error("Expected sandbox to be set");
|
||||
}
|
||||
const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath));
|
||||
|
||||
const stagedPath = `media/inbound/${basename(mediaPath)}`;
|
||||
expect(ctx.MediaPath).toBe(stagedPath);
|
||||
expect(sessionCtx.MediaPath).toBe(stagedPath);
|
||||
expect(ctx.MediaUrl).toBe(stagedPath);
|
||||
expect(sessionCtx.MediaUrl).toBe(stagedPath);
|
||||
|
||||
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(mediaPath));
|
||||
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseAudioTag } from "./audio-tags.js";
|
||||
|
||||
describe("parseAudioTag", () => {
|
||||
it("detects audio_as_voice and strips the tag", () => {
|
||||
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.hadTag).toBe(true);
|
||||
expect(result.text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("returns empty output for missing text", () => {
|
||||
const result = parseAudioTag(undefined);
|
||||
expect(result.audioAsVoice).toBe(false);
|
||||
expect(result.hadTag).toBe(false);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("removes tag-only messages", () => {
|
||||
const result = parseAudioTag("[[audio_as_voice]]");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||
|
||||
describe("block reply coalescer", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("coalesces chunks within the idle window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hello" });
|
||||
coalescer.enqueue({ text: "world" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(flushes).toEqual(["Hello world"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("waits until minChars before idle flush", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "short" });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual([]);
|
||||
|
||||
coalescer.enqueue({ text: "message" });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual(["short message"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes buffered text before media payloads", () => {
|
||||
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push({
|
||||
text: payload.text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hello" });
|
||||
coalescer.enqueue({ text: "world" });
|
||||
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
|
||||
void coalescer.flush({ force: true });
|
||||
|
||||
expect(flushes[0].text).toBe("Hello world");
|
||||
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/config.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||
"../../pairing/pairing-store.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||
"../../channels/plugins/pairing.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listPairingChannels: () => ["telegram"],
|
||||
};
|
||||
});
|
||||
|
||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||
const ctx = {
|
||||
Body: commandBody,
|
||||
CommandBody: commandBody,
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
...ctxOverrides,
|
||||
} as MsgContext;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives: parseInlineDirectives(commandBody),
|
||||
elevated: { enabled: true, allowed: true, failures: [] },
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp",
|
||||
defaultGroupActivation: () => "mention",
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "telegram",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleCommands /allowlist", () => {
|
||||
it("lists config + store allowFrom entries", async () => {
|
||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist list dm", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Channel: telegram");
|
||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||
});
|
||||
|
||||
it("adds entries to config and pairing store", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: ["123", "789"],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist add dm 789", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||
}),
|
||||
);
|
||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
});
|
||||
expect(result.reply?.text).toContain("DM allowlist added");
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||
const ctx = {
|
||||
Body: commandBody,
|
||||
CommandBody: commandBody,
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
...ctxOverrides,
|
||||
} as MsgContext;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives: parseInlineDirectives(commandBody),
|
||||
elevated: { enabled: true, allowed: true, failures: [] },
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp",
|
||||
defaultGroupActivation: () => "mention",
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "whatsapp",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleCommands /config configWrites gating", () => {
|
||||
it("blocks /config set when channel config writes are disabled", async () => {
|
||||
const cfg = {
|
||||
commands: { config: true, text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Config writes are disabled");
|
||||
});
|
||||
});
|
||||
125
src/auto-reply/reply/commands-parsing.test.ts
Normal file
125
src/auto-reply/reply/commands-parsing.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { extractMessageText } from "./commands-subagents.js";
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||
const ctx = {
|
||||
Body: commandBody,
|
||||
CommandBody: commandBody,
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
...ctxOverrides,
|
||||
} as MsgContext;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives: parseInlineDirectives(commandBody),
|
||||
elevated: { enabled: true, allowed: true, failures: [] },
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp",
|
||||
defaultGroupActivation: () => "mention",
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "whatsapp",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseConfigCommand", () => {
|
||||
it("parses show/unset", () => {
|
||||
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
||||
expect(parseConfigCommand("/config show")).toEqual({
|
||||
action: "show",
|
||||
path: undefined,
|
||||
});
|
||||
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
||||
action: "show",
|
||||
path: "foo.bar",
|
||||
});
|
||||
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
||||
action: "show",
|
||||
path: "foo.bar",
|
||||
});
|
||||
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
||||
action: "unset",
|
||||
path: "foo.bar",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDebugCommand", () => {
|
||||
it("parses show/reset", () => {
|
||||
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
||||
});
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
|
||||
it("parses unset", () => {
|
||||
const cmd = parseDebugCommand("/debug unset foo.bar");
|
||||
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText", () => {
|
||||
it("preserves user text that looks like tool call markers", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
||||
});
|
||||
|
||||
it("sanitizes assistant tool call markers", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toBe("Here ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands /config configWrites gating", () => {
|
||||
it("blocks /config set when channel config writes are disabled", async () => {
|
||||
const cfg = {
|
||||
commands: { config: true, text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Config writes are disabled");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,47 @@ import type { MsgContext } from "../templating.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/config.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||
"../../pairing/pairing-store.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||
"../../channels/plugins/pairing.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listPairingChannels: () => ["telegram"],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(async () => [
|
||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
||||
@@ -46,17 +87,70 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
contextTokens: 16000,
|
||||
provider: "telegram",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleCommands /allowlist", () => {
|
||||
it("lists config + store allowFrom entries", async () => {
|
||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist list dm", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Channel: telegram");
|
||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||
});
|
||||
|
||||
it("adds entries to config and pairing store", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: ["123", "789"],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist add dm 789", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||
}),
|
||||
);
|
||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
});
|
||||
expect(result.reply?.text).toContain("DM allowlist added");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/models command", () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
// allowlist is empty => allowAny, but still okay for listing
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
} as unknown as ClawdbotConfig;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractMessageText } from "./commands-subagents.js";
|
||||
|
||||
describe("extractMessageText", () => {
|
||||
it("preserves user text that looks like tool call markers", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
||||
});
|
||||
|
||||
it("sanitizes assistant tool call markers", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toBe("Here ok");
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
|
||||
describe("parseConfigCommand", () => {
|
||||
it("parses show/unset", () => {
|
||||
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
||||
expect(parseConfigCommand("/config show")).toEqual({
|
||||
action: "show",
|
||||
path: undefined,
|
||||
});
|
||||
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
||||
action: "show",
|
||||
path: "foo.bar",
|
||||
});
|
||||
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
||||
action: "show",
|
||||
path: "foo.bar",
|
||||
});
|
||||
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
||||
action: "unset",
|
||||
path: "foo.bar",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
|
||||
describe("parseDebugCommand", () => {
|
||||
it("parses show/reset", () => {
|
||||
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
||||
});
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
|
||||
it("parses unset", () => {
|
||||
const cmd = parseDebugCommand("/debug unset foo.bar");
|
||||
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ModelAliasIndex } from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
import {
|
||||
maybeHandleModelDirectiveInfo,
|
||||
resolveModelSelectionFromDirective,
|
||||
} from "./directive-handling.model.js";
|
||||
|
||||
function baseAliasIndex(): ModelAliasIndex {
|
||||
return { byAlias: new Map(), byKey: new Map() };
|
||||
}
|
||||
|
||||
describe("/model chat UX", () => {
|
||||
it("shows summary for /model with no args", async () => {
|
||||
const directives = parseInlineDirectives("/model");
|
||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||
|
||||
const reply = await maybeHandleModelDirectiveInfo({
|
||||
directives,
|
||||
cfg,
|
||||
agentDir: "/tmp/agent",
|
||||
activeAgentId: "main",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelCatalog: [],
|
||||
resetModelOverride: false,
|
||||
});
|
||||
|
||||
expect(reply?.text).toContain("Current:");
|
||||
expect(reply?.text).toContain("Browse: /models");
|
||||
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
||||
});
|
||||
|
||||
it("auto-applies closest match for typos", () => {
|
||||
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||
|
||||
const resolved = resolveModelSelectionFromDirective({
|
||||
directives,
|
||||
cfg,
|
||||
agentDir: "/tmp/agent",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
|
||||
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
|
||||
provider: "anthropic",
|
||||
});
|
||||
|
||||
expect(resolved.modelSelection).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
isDefault: true,
|
||||
});
|
||||
expect(resolved.errorText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,12 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
import { handleDirectiveOnly } from "./directive-handling.impl.js";
|
||||
import {
|
||||
maybeHandleModelDirectiveInfo,
|
||||
resolveModelSelectionFromDirective,
|
||||
} from "./directive-handling.model.js";
|
||||
|
||||
// Mock dependencies
|
||||
// Mock dependencies for directive handling persistence.
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentConfig: vi.fn(() => ({})),
|
||||
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
||||
@@ -36,6 +40,55 @@ function baseConfig(): ClawdbotConfig {
|
||||
} as unknown as ClawdbotConfig;
|
||||
}
|
||||
|
||||
describe("/model chat UX", () => {
|
||||
it("shows summary for /model with no args", async () => {
|
||||
const directives = parseInlineDirectives("/model");
|
||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||
|
||||
const reply = await maybeHandleModelDirectiveInfo({
|
||||
directives,
|
||||
cfg,
|
||||
agentDir: "/tmp/agent",
|
||||
activeAgentId: "main",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelCatalog: [],
|
||||
resetModelOverride: false,
|
||||
});
|
||||
|
||||
expect(reply?.text).toContain("Current:");
|
||||
expect(reply?.text).toContain("Browse: /models");
|
||||
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
||||
});
|
||||
|
||||
it("auto-applies closest match for typos", () => {
|
||||
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||
|
||||
const resolved = resolveModelSelectionFromDirective({
|
||||
directives,
|
||||
cfg,
|
||||
agentDir: "/tmp/agent",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
|
||||
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
|
||||
provider: "anthropic",
|
||||
});
|
||||
|
||||
expect(resolved.modelSelection).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
isDefault: true,
|
||||
});
|
||||
expect(resolved.errorText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
|
||||
const allowedModelCatalog = [
|
||||
@@ -106,7 +159,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
// No model directive = no model message
|
||||
expect(result?.text ?? "").not.toContain("Model set to");
|
||||
expect(result?.text ?? "").not.toContain("failed");
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||
}));
|
||||
|
||||
import { createFollowupRunner } from "./followup-runner.js";
|
||||
|
||||
describe("createFollowupRunner compaction", () => {
|
||||
it("adds verbose auto-compaction notice and tracks count", async () => {
|
||||
const storePath = path.join(
|
||||
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
||||
"sessions.json",
|
||||
);
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
main: sessionEntry,
|
||||
};
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const runner = createFollowupRunner({
|
||||
opts: { onBlockReply },
|
||||
typing: createMockTypingController(),
|
||||
typingMode: "instant",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
|
||||
const queued = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "on",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as FollowupRun;
|
||||
|
||||
await runner(queued);
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalled();
|
||||
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
|
||||
expect(sessionStore.main.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
@@ -57,6 +61,79 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
|
||||
},
|
||||
}) as FollowupRun;
|
||||
|
||||
describe("createFollowupRunner compaction", () => {
|
||||
it("adds verbose auto-compaction notice and tracks count", async () => {
|
||||
const storePath = path.join(
|
||||
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
||||
"sessions.json",
|
||||
);
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
main: sessionEntry,
|
||||
};
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const runner = createFollowupRunner({
|
||||
opts: { onBlockReply },
|
||||
typing: createMockTypingController(),
|
||||
typingMode: "instant",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
|
||||
const queued = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "on",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as FollowupRun;
|
||||
|
||||
await runner(queued);
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalled();
|
||||
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
|
||||
expect(sessionStore.main.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFollowupRunner messaging tool dedupe", () => {
|
||||
it("drops payloads already sent via messaging tool", async () => {
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
185
src/auto-reply/reply/formatting.test.ts
Normal file
185
src/auto-reply/reply/formatting.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { parseAudioTag } from "./audio-tags.js";
|
||||
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||
import { createReplyReferencePlanner } from "./reply-reference.js";
|
||||
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
|
||||
|
||||
describe("parseAudioTag", () => {
|
||||
it("detects audio_as_voice and strips the tag", () => {
|
||||
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.hadTag).toBe(true);
|
||||
expect(result.text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("returns empty output for missing text", () => {
|
||||
const result = parseAudioTag(undefined);
|
||||
expect(result.audioAsVoice).toBe(false);
|
||||
expect(result.hadTag).toBe(false);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("removes tag-only messages", () => {
|
||||
const result = parseAudioTag("[[audio_as_voice]]");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("block reply coalescer", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("coalesces chunks within the idle window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hello" });
|
||||
coalescer.enqueue({ text: "world" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(flushes).toEqual(["Hello world"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("waits until minChars before idle flush", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "short" });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual([]);
|
||||
|
||||
coalescer.enqueue({ text: "message" });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual(["short message"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes buffered text before media payloads", () => {
|
||||
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push({
|
||||
text: payload.text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hello" });
|
||||
coalescer.enqueue({ text: "world" });
|
||||
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
|
||||
void coalescer.flush({ force: true });
|
||||
|
||||
expect(flushes[0].text).toBe("Hello world");
|
||||
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReplyReferencePlanner", () => {
|
||||
it("disables references when mode is off", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "off",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBeUndefined();
|
||||
expect(planner.hasReplied()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses startId once when mode is first", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "first",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("parent");
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
planner.markSent();
|
||||
expect(planner.use()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns startId for every call when mode is all", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "all",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("parent");
|
||||
expect(planner.use()).toBe("parent");
|
||||
});
|
||||
|
||||
it("prefers existing thread id regardless of mode", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "off",
|
||||
existingId: "thread-1",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("thread-1");
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
});
|
||||
|
||||
it("honors allowReference=false", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "all",
|
||||
startId: "parent",
|
||||
allowReference: false,
|
||||
});
|
||||
expect(planner.use()).toBeUndefined();
|
||||
expect(planner.hasReplied()).toBe(false);
|
||||
planner.markSent();
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStreamingDirectiveAccumulator", () => {
|
||||
it("stashes reply_to_current until a renderable chunk arrives", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hello");
|
||||
expect(result?.text).toBe("Hello");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("handles reply tags split across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("current]] Yo");
|
||||
expect(result?.text).toBe("Yo");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates explicit reply ids across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hi");
|
||||
expect(result?.text).toBe("Hi");
|
||||
expect(result?.replyToId).toBe("abc-123");
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { GroupKeyResolution } from "../../config/sessions.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { resolveGroupRequireMention } from "./groups.js";
|
||||
|
||||
describe("resolveGroupRequireMention", () => {
|
||||
it("respects Discord guild/channel requireMention settings", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
guilds: {
|
||||
"145": {
|
||||
requireMention: false,
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Provider: "discord",
|
||||
From: "discord:group:123",
|
||||
GroupChannel: "#general",
|
||||
GroupSpace: "145",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
channel: "discord",
|
||||
id: "123",
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
|
||||
it("respects Slack channel requireMention settings", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
channels: {
|
||||
C123: { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Provider: "slack",
|
||||
From: "slack:channel:C123",
|
||||
GroupSubject: "#general",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
channel: "slack",
|
||||
id: "C123",
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { finalizeInboundContext } from "./inbound-context.js";
|
||||
|
||||
describe("finalizeInboundContext", () => {
|
||||
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
|
||||
const ctx: MsgContext = {
|
||||
Body: "a\\nb\r\nc",
|
||||
RawBody: "raw\\nline",
|
||||
ChatType: "channel",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
GroupSubject: "Test",
|
||||
};
|
||||
|
||||
const out = finalizeInboundContext(ctx);
|
||||
expect(out.Body).toBe("a\nb\nc");
|
||||
expect(out.RawBody).toBe("raw\nline");
|
||||
expect(out.BodyForAgent).toBe("a\nb\nc");
|
||||
expect(out.BodyForCommands).toBe("raw\nline");
|
||||
expect(out.CommandAuthorized).toBe(false);
|
||||
expect(out.ChatType).toBe("channel");
|
||||
expect(out.ConversationLabel).toContain("Test");
|
||||
});
|
||||
|
||||
it("can force BodyForCommands to follow updated CommandBody", () => {
|
||||
const ctx: MsgContext = {
|
||||
Body: "base",
|
||||
BodyForCommands: "<media:audio>",
|
||||
CommandBody: "say hi",
|
||||
From: "signal:+15550001111",
|
||||
ChatType: "direct",
|
||||
};
|
||||
|
||||
finalizeInboundContext(ctx, { forceBodyForCommands: true });
|
||||
expect(ctx.BodyForCommands).toBe("say hi");
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import {
|
||||
buildInboundDedupeKey,
|
||||
resetInboundDedupe,
|
||||
shouldSkipDuplicateInbound,
|
||||
} from "./inbound-dedupe.js";
|
||||
|
||||
describe("inbound dedupe", () => {
|
||||
it("builds a stable key when MessageSid is present", () => {
|
||||
const ctx: MsgContext = {
|
||||
Provider: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:123",
|
||||
MessageSid: "42",
|
||||
};
|
||||
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
|
||||
});
|
||||
|
||||
it("skips duplicates with the same key", () => {
|
||||
resetInboundDedupe();
|
||||
const ctx: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+1555",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
|
||||
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not dedupe when the peer changes", () => {
|
||||
resetInboundDedupe();
|
||||
const base: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not dedupe across session keys", () => {
|
||||
resetInboundDedupe();
|
||||
const base: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+1555",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||
|
||||
describe("formatInboundBodyWithSenderMeta", () => {
|
||||
it("does nothing for direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
|
||||
});
|
||||
|
||||
it("appends a sender meta line for non-direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers SenderE164 in the label when present", () => {
|
||||
const ctx: MsgContext = {
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderId: "bob@s.whatsapp.net",
|
||||
SenderE164: "+222",
|
||||
};
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||
"[X] hi\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends with a real newline even if the body contains literal \\\\n", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
|
||||
"[X] one\\n[X] two\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate a sender meta line when one is already present", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not append when the body already includes a sender prefix", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
|
||||
});
|
||||
|
||||
it("does not append when the sender prefix follows an envelope header", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
|
||||
"[Signal Group] Alice (A1): hi",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeInboundTextNewlines } from "./inbound-text.js";
|
||||
|
||||
describe("normalizeInboundTextNewlines", () => {
|
||||
it("keeps real newlines", () => {
|
||||
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("normalizes CRLF/CR to LF", () => {
|
||||
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
|
||||
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("decodes literal \\\\n to newlines when no real newlines exist", () => {
|
||||
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildMentionRegexes, matchesMentionPatterns, normalizeMentionText } from "./mentions.js";
|
||||
|
||||
describe("mention helpers", () => {
|
||||
it("builds regexes and skips invalid patterns", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
|
||||
},
|
||||
});
|
||||
expect(regexes).toHaveLength(1);
|
||||
expect(regexes[0]?.test("clawd")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes zero-width characters", () => {
|
||||
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
|
||||
});
|
||||
|
||||
it("matches patterns case-insensitively", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
|
||||
});
|
||||
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses per-agent mention patterns when configured", () => {
|
||||
const regexes = buildMentionRegexes(
|
||||
{
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"work",
|
||||
);
|
||||
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
|
||||
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createReplyReferencePlanner } from "./reply-reference.js";
|
||||
|
||||
describe("createReplyReferencePlanner", () => {
|
||||
it("disables references when mode is off", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "off",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBeUndefined();
|
||||
expect(planner.hasReplied()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses startId once when mode is first", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "first",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("parent");
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
planner.markSent();
|
||||
expect(planner.use()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns startId for every call when mode is all", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "all",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("parent");
|
||||
expect(planner.use()).toBe("parent");
|
||||
});
|
||||
|
||||
it("prefers existing thread id regardless of mode", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "off",
|
||||
existingId: "thread-1",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("thread-1");
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
});
|
||||
|
||||
it("honors allowReference=false", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "all",
|
||||
startId: "parent",
|
||||
allowReference: false,
|
||||
});
|
||||
expect(planner.use()).toBeUndefined();
|
||||
expect(planner.hasReplied()).toBe(false);
|
||||
planner.markSent();
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
|
||||
|
||||
const emptyCfg = {} as ClawdbotConfig;
|
||||
|
||||
describe("createReplyDispatcher", () => {
|
||||
it("drops empty payloads and silent tokens without media", async () => {
|
||||
@@ -150,3 +155,94 @@ describe("createReplyDispatcher", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReplyToMode", () => {
|
||||
it("defaults to first for Telegram", () => {
|
||||
expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("first");
|
||||
});
|
||||
|
||||
it("defaults to off for Discord and Slack", () => {
|
||||
expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off");
|
||||
expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off");
|
||||
});
|
||||
|
||||
it("defaults to all when channel is unknown", () => {
|
||||
expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all");
|
||||
});
|
||||
|
||||
it("uses configured value when present", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { replyToMode: "all" },
|
||||
discord: { replyToMode: "first" },
|
||||
slack: { replyToMode: "all" },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "telegram")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
||||
});
|
||||
|
||||
it("uses chat-type replyToMode overrides for Slack when configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all", group: "first" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
|
||||
});
|
||||
|
||||
it("falls back to top-level replyToMode when no chat-type override is set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
|
||||
});
|
||||
|
||||
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
dm: { replyToMode: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReplyToModeFilter", () => {
|
||||
it("drops replyToId when mode is off", () => {
|
||||
const filter = createReplyToModeFilter("off");
|
||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps replyToId when mode is off and reply tags are allowed", () => {
|
||||
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
|
||||
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
|
||||
});
|
||||
|
||||
it("keeps replyToId when mode is all", () => {
|
||||
const filter = createReplyToModeFilter("all");
|
||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
||||
});
|
||||
|
||||
it("keeps only the first replyToId when mode is first", () => {
|
||||
const filter = createReplyToModeFilter("first");
|
||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
||||
expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
|
||||
|
||||
const emptyCfg = {} as ClawdbotConfig;
|
||||
|
||||
describe("resolveReplyToMode", () => {
|
||||
it("defaults to first for Telegram", () => {
|
||||
expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("first");
|
||||
});
|
||||
|
||||
it("defaults to off for Discord and Slack", () => {
|
||||
expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off");
|
||||
expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off");
|
||||
});
|
||||
|
||||
it("defaults to all when channel is unknown", () => {
|
||||
expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all");
|
||||
});
|
||||
|
||||
it("uses configured value when present", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { replyToMode: "all" },
|
||||
discord: { replyToMode: "first" },
|
||||
slack: { replyToMode: "all" },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "telegram")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
||||
});
|
||||
|
||||
it("uses chat-type replyToMode overrides for Slack when configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all", group: "first" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
|
||||
});
|
||||
|
||||
it("falls back to top-level replyToMode when no chat-type override is set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
|
||||
});
|
||||
|
||||
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
dm: { replyToMode: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReplyToModeFilter", () => {
|
||||
it("drops replyToId when mode is off", () => {
|
||||
const filter = createReplyToModeFilter("off");
|
||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps replyToId when mode is off and reply tags are allowed", () => {
|
||||
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
|
||||
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
|
||||
});
|
||||
|
||||
it("keeps replyToId when mode is all", () => {
|
||||
const filter = createReplyToModeFilter("all");
|
||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
||||
});
|
||||
|
||||
it("keeps only the first replyToId when mode is first", () => {
|
||||
const filter = createReplyToModeFilter("first");
|
||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
||||
expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { buildModelAliasIndex } from "../../agents/model-selection.js";
|
||||
import { applyResetModelOverride } from "./session-reset-model.js";
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(async () => [
|
||||
{ provider: "minimax", id: "m2.1", name: "M2.1" },
|
||||
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
|
||||
]),
|
||||
}));
|
||||
|
||||
describe("applyResetModelOverride", () => {
|
||||
it("selects a model hint and strips it from the body", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||
const sessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||
const ctx = { ChatType: "direct" };
|
||||
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
resetTriggered: true,
|
||||
bodyStripped: "minimax summarize",
|
||||
sessionCtx,
|
||||
ctx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
expect(sessionEntry.providerOverride).toBe("minimax");
|
||||
expect(sessionEntry.modelOverride).toBe("m2.1");
|
||||
expect(sessionCtx.BodyStripped).toBe("summarize");
|
||||
});
|
||||
|
||||
it("clears auth profile overrides when reset applies a model", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||
const sessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
authProfileOverride: "anthropic:default",
|
||||
authProfileOverrideSource: "user",
|
||||
authProfileOverrideCompactionCount: 2,
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||
const ctx = { ChatType: "direct" };
|
||||
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
resetTriggered: true,
|
||||
bodyStripped: "minimax summarize",
|
||||
sessionCtx,
|
||||
ctx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
expect(sessionEntry.authProfileOverride).toBeUndefined();
|
||||
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
|
||||
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips when resetTriggered is false", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||
const sessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||
const ctx = { ChatType: "direct" };
|
||||
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
resetTriggered: false,
|
||||
bodyStripped: "minimax summarize",
|
||||
sessionCtx,
|
||||
ctx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
expect(sessionEntry.providerOverride).toBeUndefined();
|
||||
expect(sessionEntry.modelOverride).toBeUndefined();
|
||||
expect(sessionCtx.BodyStripped).toBe("minimax summarize");
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,21 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildModelAliasIndex } from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
import { applyResetModelOverride } from "./session-reset-model.js";
|
||||
import { prependSystemEvents } from "./session-updates.js";
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(async () => [
|
||||
{ provider: "minimax", id: "m2.1", name: "M2.1" },
|
||||
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
|
||||
]),
|
||||
}));
|
||||
|
||||
describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
async function createStorePath(prefix: string): Promise<string> {
|
||||
@@ -54,7 +65,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
allowFrom: ["+41796666864"],
|
||||
});
|
||||
|
||||
// Group message context matching what WhatsApp handler creates
|
||||
const groupMessageCtx = {
|
||||
Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`,
|
||||
RawBody: "/new",
|
||||
@@ -76,7 +86,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
// The reset should be detected
|
||||
expect(result.triggerBodyNormalized).toBe("/new");
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
@@ -99,7 +108,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
allowFrom: ["+41796666864"],
|
||||
});
|
||||
|
||||
// Group message from different sender (not in allowFrom)
|
||||
const groupMessageCtx = {
|
||||
Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`,
|
||||
RawBody: "/new",
|
||||
@@ -111,7 +119,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
SenderName: "OtherPerson",
|
||||
SenderE164: "+1555123456", // Different sender (not authorized)
|
||||
SenderE164: "+1555123456",
|
||||
SenderId: "1555123456:0@s.whatsapp.net",
|
||||
};
|
||||
|
||||
@@ -121,9 +129,8 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
// Reset should NOT be triggered for unauthorized sender - session ID should stay the same
|
||||
expect(result.triggerBodyNormalized).toBe("/new");
|
||||
expect(result.sessionId).toBe(existingSessionId); // Session should NOT change
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
expect(result.isNewSession).toBe(false);
|
||||
});
|
||||
|
||||
@@ -143,9 +150,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
});
|
||||
|
||||
const groupMessageCtx = {
|
||||
// Body is wrapped with context prefixes
|
||||
Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`,
|
||||
// RawBody is clean
|
||||
RawBody: "/new",
|
||||
CommandBody: "/new",
|
||||
From: "120363406150318674@g.us",
|
||||
@@ -251,3 +256,124 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
expect(result.isNewSession).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyResetModelOverride", () => {
|
||||
it("selects a model hint and strips it from the body", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||
const sessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||
const ctx = { ChatType: "direct" };
|
||||
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
resetTriggered: true,
|
||||
bodyStripped: "minimax summarize",
|
||||
sessionCtx,
|
||||
ctx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
expect(sessionEntry.providerOverride).toBe("minimax");
|
||||
expect(sessionEntry.modelOverride).toBe("m2.1");
|
||||
expect(sessionCtx.BodyStripped).toBe("summarize");
|
||||
});
|
||||
|
||||
it("clears auth profile overrides when reset applies a model", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||
const sessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
authProfileOverride: "anthropic:default",
|
||||
authProfileOverrideSource: "user",
|
||||
authProfileOverrideCompactionCount: 2,
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||
const ctx = { ChatType: "direct" };
|
||||
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
resetTriggered: true,
|
||||
bodyStripped: "minimax summarize",
|
||||
sessionCtx,
|
||||
ctx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
expect(sessionEntry.authProfileOverride).toBeUndefined();
|
||||
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
|
||||
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips when resetTriggered is false", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||
const sessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||
const ctx = { ChatType: "direct" };
|
||||
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
resetTriggered: false,
|
||||
bodyStripped: "minimax summarize",
|
||||
sessionCtx,
|
||||
ctx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
expect(sessionEntry.providerOverride).toBeUndefined();
|
||||
expect(sessionEntry.modelOverride).toBeUndefined();
|
||||
expect(sessionCtx.BodyStripped).toBe("minimax summarize");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prependSystemEvents", () => {
|
||||
it("adds a local timestamp to queued system events by default", async () => {
|
||||
vi.useFakeTimers();
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
const timestamp = new Date("2026-01-12T20:19:17Z");
|
||||
vi.setSystemTime(timestamp);
|
||||
|
||||
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
|
||||
|
||||
const result = await prependSystemEvents({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
sessionKey: "agent:main:main",
|
||||
isMainSession: false,
|
||||
isNewSession: false,
|
||||
prefixedBodyBase: "User: hi",
|
||||
});
|
||||
|
||||
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
|
||||
|
||||
resetSystemEventsForTest();
|
||||
process.env.TZ = originalTz;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||
import { prependSystemEvents } from "./session-updates.js";
|
||||
|
||||
describe("prependSystemEvents", () => {
|
||||
it("adds a local timestamp to queued system events by default", async () => {
|
||||
vi.useFakeTimers();
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
const timestamp = new Date("2026-01-12T20:19:17Z");
|
||||
vi.setSystemTime(timestamp);
|
||||
|
||||
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
|
||||
|
||||
const result = await prependSystemEvents({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
sessionKey: "agent:main:main",
|
||||
isMainSession: false,
|
||||
isNewSession: false,
|
||||
prefixedBodyBase: "User: hi",
|
||||
});
|
||||
|
||||
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
|
||||
|
||||
resetSystemEventsForTest();
|
||||
process.env.TZ = originalTz;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
|
||||
describe("initSessionState sender meta", () => {
|
||||
it("injects sender meta into BodyStripped for group chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp 123@g.us] ping",
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SenderId: "222@s.whatsapp.net",
|
||||
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("does not inject sender meta for direct chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp +1] ping",
|
||||
ChatType: "direct",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SessionKey: "agent:main:whatsapp:dm:+222",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
|
||||
|
||||
describe("createStreamingDirectiveAccumulator", () => {
|
||||
it("stashes reply_to_current until a renderable chunk arrives", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hello");
|
||||
expect(result?.text).toBe("Hello");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("handles reply tags split across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("current]] Yo");
|
||||
expect(result?.text).toBe("Yo");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates explicit reply ids across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hi");
|
||||
expect(result?.text).toBe("Hi");
|
||||
expect(result?.replyToId).toBe("abc-123");
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
|
||||
|
||||
describe("resolveTypingMode", () => {
|
||||
it("defaults to instant for direct chats", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("instant");
|
||||
});
|
||||
|
||||
it("defaults to message for group chats without mentions", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("message");
|
||||
});
|
||||
|
||||
it("defaults to instant for mentioned group chats", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("instant");
|
||||
});
|
||||
|
||||
it("honors configured mode across contexts", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "thinking",
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("thinking");
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "message",
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("message");
|
||||
});
|
||||
|
||||
it("forces never for heartbeat runs", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "instant",
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: true,
|
||||
}),
|
||||
).toBe("never");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTypingSignaler", () => {
|
||||
it("signals immediately for instant mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "instant",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalRunStart();
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals on text for message mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hello");
|
||||
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals on message start for message mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalMessageStart();
|
||||
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
await signaler.signalTextDelta("hello");
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("signals on reasoning for thinking mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "thinking",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalReasoningDelta();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
await signaler.signalTextDelta("hi");
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes ttl on text for thinking mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "thinking",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hi");
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts typing on tool start before text", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalToolStart();
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes ttl on tool start when active after text", async () => {
|
||||
const typing = createMockTypingController({
|
||||
isActive: vi.fn(() => true),
|
||||
});
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hello");
|
||||
typing.startTypingLoop.mockClear();
|
||||
typing.startTypingOnText.mockClear();
|
||||
typing.refreshTypingTtl.mockClear();
|
||||
await signaler.signalToolStart();
|
||||
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses typing when disabled", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "instant",
|
||||
isHeartbeat: true,
|
||||
});
|
||||
|
||||
await signaler.signalRunStart();
|
||||
await signaler.signalTextDelta("hi");
|
||||
await signaler.signalReasoningDelta();
|
||||
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
|
||||
import { createTypingController } from "./typing.js";
|
||||
|
||||
describe("typing controller", () => {
|
||||
@@ -91,3 +93,192 @@ describe("typing controller", () => {
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTypingMode", () => {
|
||||
it("defaults to instant for direct chats", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("instant");
|
||||
});
|
||||
|
||||
it("defaults to message for group chats without mentions", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("message");
|
||||
});
|
||||
|
||||
it("defaults to instant for mentioned group chats", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("instant");
|
||||
});
|
||||
|
||||
it("honors configured mode across contexts", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "thinking",
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("thinking");
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "message",
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("message");
|
||||
});
|
||||
|
||||
it("forces never for heartbeat runs", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "instant",
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: true,
|
||||
}),
|
||||
).toBe("never");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTypingSignaler", () => {
|
||||
it("signals immediately for instant mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "instant",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalRunStart();
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals on text for message mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hello");
|
||||
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals on message start for message mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalMessageStart();
|
||||
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
await signaler.signalTextDelta("hello");
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("signals on reasoning for thinking mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "thinking",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalReasoningDelta();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
await signaler.signalTextDelta("hi");
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes ttl on text for thinking mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "thinking",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hi");
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts typing on tool start before text", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalToolStart();
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes ttl on tool start when active after text", async () => {
|
||||
const typing = createMockTypingController({
|
||||
isActive: vi.fn(() => true),
|
||||
});
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hello");
|
||||
typing.startTypingLoop.mockClear();
|
||||
typing.startTypingOnText.mockClear();
|
||||
typing.refreshTypingTtl.mockClear();
|
||||
await signaler.signalToolStart();
|
||||
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses typing when disabled", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "instant",
|
||||
isHeartbeat: true,
|
||||
});
|
||||
|
||||
await signaler.signalRunStart();
|
||||
await signaler.signalTextDelta("hi");
|
||||
await signaler.signalReasoningDelta();
|
||||
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyTemplate, type TemplateContext } from "./templating.js";
|
||||
|
||||
describe("applyTemplate", () => {
|
||||
it("renders primitive values", () => {
|
||||
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
|
||||
const overrides = ctx as Record<string, unknown>;
|
||||
overrides.MessageSid = 42;
|
||||
overrides.IsNewSession = true;
|
||||
|
||||
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
|
||||
});
|
||||
|
||||
it("renders arrays of primitives", () => {
|
||||
const ctx = { MediaPaths: ["a"] } as TemplateContext;
|
||||
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
|
||||
|
||||
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
|
||||
});
|
||||
|
||||
it("drops object values", () => {
|
||||
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
|
||||
|
||||
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
|
||||
});
|
||||
|
||||
it("renders missing placeholders as empty", () => {
|
||||
const ctx: TemplateContext = {};
|
||||
|
||||
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user