diff --git a/src/auto-reply/command-auth.test.ts b/src/auto-reply/command-auth.test.ts deleted file mode 100644 index 0b6cf0826..000000000 --- a/src/auto-reply/command-auth.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-control.test.ts similarity index 56% rename from src/auto-reply/command-detection.test.ts rename to src/auto-reply/command-control.test.ts index 66f9d15c7..cb65e60ef 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -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({ diff --git a/src/auto-reply/commands-registry.args.test.ts b/src/auto-reply/commands-registry.args.test.ts deleted file mode 100644 index cee8cf5f3..000000000 --- a/src/auto-reply/commands-registry.args.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 4296e06cd..e1192c9cd 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -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(); + }); +}); diff --git a/src/auto-reply/inbound-debounce.test.ts b/src/auto-reply/inbound-debounce.test.ts deleted file mode 100644 index a50d403b4..000000000 --- a/src/auto-reply/inbound-debounce.test.ts +++ /dev/null @@ -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 = []; - - 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 = []; - - 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(); - }); -}); diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts new file mode 100644 index 000000000..c58b98e54 --- /dev/null +++ b/src/auto-reply/inbound.test.ts @@ -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; + 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).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: "", + 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 = []; + + 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 = []; + + 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); + }); +}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index 23e66f1a5..798ddb28b 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -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(fn: (home: string) => Promise): Promise { - 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(); }); }); diff --git a/src/auto-reply/reply/audio-tags.test.ts b/src/auto-reply/reply/audio-tags.test.ts deleted file mode 100644 index 48d952c15..000000000 --- a/src/auto-reply/reply/audio-tags.test.ts +++ /dev/null @@ -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(""); - }); -}); diff --git a/src/auto-reply/reply/block-reply-coalescer.test.ts b/src/auto-reply/reply/block-reply-coalescer.test.ts deleted file mode 100644 index 06f7e42cc..000000000 --- a/src/auto-reply/reply/block-reply-coalescer.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts deleted file mode 100644 index 60c6fdecd..000000000 --- a/src/auto-reply/reply/commands-allowlist.test.ts +++ /dev/null @@ -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("../../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( - "../../pairing/pairing-store.js", - ); - return { - ...actual, - readChannelAllowFromStore: readChannelAllowFromStoreMock, - addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, - removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, - }; -}); - -vi.mock("../../channels/plugins/pairing.js", async () => { - const actual = await vi.importActual( - "../../channels/plugins/pairing.js", - ); - return { - ...actual, - listPairingChannels: () => ["telegram"], - }; -}); - -function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { - 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"); - }); -}); diff --git a/src/auto-reply/reply/commands-config-writes.test.ts b/src/auto-reply/reply/commands-config-writes.test.ts deleted file mode 100644 index 7c55c3a01..000000000 --- a/src/auto-reply/reply/commands-config-writes.test.ts +++ /dev/null @@ -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) { - 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"); - }); -}); diff --git a/src/auto-reply/reply/commands-parsing.test.ts b/src/auto-reply/reply/commands-parsing.test.ts new file mode 100644 index 000000000..1c60dc98a --- /dev/null +++ b/src/auto-reply/reply/commands-parsing.test.ts @@ -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) { + 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"); + }); +}); diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-policy.test.ts similarity index 59% rename from src/auto-reply/reply/commands-models.test.ts rename to src/auto-reply/reply/commands-policy.test.ts index c32abfc7d..5ba5026a7 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-policy.test.ts @@ -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("../../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( + "../../pairing/pairing-store.js", + ); + return { + ...actual, + readChannelAllowFromStore: readChannelAllowFromStoreMock, + addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, + removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, + }; +}); + +vi.mock("../../channels/plugins/pairing.js", async () => { + const actual = await vi.importActual( + "../../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; diff --git a/src/auto-reply/reply/commands-subagents.test.ts b/src/auto-reply/reply/commands-subagents.test.ts deleted file mode 100644 index eaf7c3026..000000000 --- a/src/auto-reply/reply/commands-subagents.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/src/auto-reply/reply/config-commands.test.ts b/src/auto-reply/reply/config-commands.test.ts deleted file mode 100644 index a1d19f039..000000000 --- a/src/auto-reply/reply/config-commands.test.ts +++ /dev/null @@ -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 } }); - }); -}); diff --git a/src/auto-reply/reply/debug-commands.test.ts b/src/auto-reply/reply/debug-commands.test.ts deleted file mode 100644 index 8c2094520..000000000 --- a/src/auto-reply/reply/debug-commands.test.ts +++ /dev/null @@ -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" }); - }); -}); diff --git a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts deleted file mode 100644 index c1e2ab7d9..000000000 --- a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts +++ /dev/null @@ -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 "); - }); - - 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(); - }); -}); diff --git a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts similarity index 66% rename from src/auto-reply/reply/directive-handling.impl.model-persist.test.ts rename to src/auto-reply/reply/directive-handling.model.test.ts index 847ff7030..abd2ff8ef 100644 --- a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -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 "); + }); + + 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"); }); diff --git a/src/auto-reply/reply/followup-runner.compaction.test.ts b/src/auto-reply/reply/followup-runner.compaction.test.ts deleted file mode 100644 index 7ea021764..000000000 --- a/src/auto-reply/reply/followup-runner.compaction.test.ts +++ /dev/null @@ -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; - }) => ({ - 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 = { - main: sessionEntry, - }; - const onBlockReply = vi.fn(async () => {}); - - runEmbeddedPiAgentMock.mockImplementationOnce( - async (params: { - onAgentEvent?: (evt: { stream: string; data: Record }) => 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); - }); -}); diff --git a/src/auto-reply/reply/followup-runner.messaging-tools.test.ts b/src/auto-reply/reply/followup-runner.test.ts similarity index 60% rename from src/auto-reply/reply/followup-runner.messaging-tools.test.ts rename to src/auto-reply/reply/followup-runner.test.ts index dd080eedc..19213081d 100644 --- a/src/auto-reply/reply/followup-runner.messaging-tools.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -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 = { + main: sessionEntry, + }; + const onBlockReply = vi.fn(async () => {}); + + runEmbeddedPiAgentMock.mockImplementationOnce( + async (params: { + onAgentEvent?: (evt: { stream: string; data: Record }) => 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 () => {}); diff --git a/src/auto-reply/reply/formatting.test.ts b/src/auto-reply/reply/formatting.test.ts new file mode 100644 index 000000000..a7a9f6174 --- /dev/null +++ b/src/auto-reply/reply/formatting.test.ts @@ -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); + }); +}); diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts deleted file mode 100644 index 6ae069141..000000000 --- a/src/auto-reply/reply/groups.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/auto-reply/reply/inbound-context.test.ts b/src/auto-reply/reply/inbound-context.test.ts deleted file mode 100644 index 58647176c..000000000 --- a/src/auto-reply/reply/inbound-context.test.ts +++ /dev/null @@ -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: "", - CommandBody: "say hi", - From: "signal:+15550001111", - ChatType: "direct", - }; - - finalizeInboundContext(ctx, { forceBodyForCommands: true }); - expect(ctx.BodyForCommands).toBe("say hi"); - }); -}); diff --git a/src/auto-reply/reply/inbound-dedupe.test.ts b/src/auto-reply/reply/inbound-dedupe.test.ts deleted file mode 100644 index d9dbd148a..000000000 --- a/src/auto-reply/reply/inbound-dedupe.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/auto-reply/reply/inbound-sender-meta.test.ts b/src/auto-reply/reply/inbound-sender-meta.test.ts deleted file mode 100644 index 2bc8d3d86..000000000 --- a/src/auto-reply/reply/inbound-sender-meta.test.ts +++ /dev/null @@ -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", - ); - }); -}); diff --git a/src/auto-reply/reply/inbound-text.test.ts b/src/auto-reply/reply/inbound-text.test.ts deleted file mode 100644 index d1ac537d5..000000000 --- a/src/auto-reply/reply/inbound-text.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts deleted file mode 100644 index d0c16977a..000000000 --- a/src/auto-reply/reply/mentions.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/auto-reply/reply/reply-reference.test.ts b/src/auto-reply/reply/reply-reference.test.ts deleted file mode 100644 index 57f29763c..000000000 --- a/src/auto-reply/reply/reply-reference.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/auto-reply/reply/reply-dispatcher.test.ts b/src/auto-reply/reply/reply-routing.test.ts similarity index 60% rename from src/auto-reply/reply/reply-dispatcher.test.ts rename to src/auto-reply/reply/reply-routing.test.ts index 3c4780505..3f369ec92 100644 --- a/src/auto-reply/reply/reply-dispatcher.test.ts +++ b/src/auto-reply/reply/reply-routing.test.ts @@ -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(); + }); +}); diff --git a/src/auto-reply/reply/reply-threading.test.ts b/src/auto-reply/reply/reply-threading.test.ts deleted file mode 100644 index 2a4e9a7f3..000000000 --- a/src/auto-reply/reply/reply-threading.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/auto-reply/reply/session-reset-model.test.ts b/src/auto-reply/reply/session-reset-model.test.ts deleted file mode 100644 index db840038c..000000000 --- a/src/auto-reply/reply/session-reset-model.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/src/auto-reply/reply/session-reset-group.test.ts b/src/auto-reply/reply/session-resets.test.ts similarity index 62% rename from src/auto-reply/reply/session-reset-group.test.ts rename to src/auto-reply/reply/session-resets.test.ts index ed08bd5a1..4f0903521 100644 --- a/src/auto-reply/reply/session-reset-group.test.ts +++ b/src/auto-reply/reply/session-resets.test.ts @@ -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 { @@ -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(); + }); +}); diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts deleted file mode 100644 index d673e2b4f..000000000 --- a/src/auto-reply/reply/session-updates.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/auto-reply/reply/session.sender-meta.test.ts b/src/auto-reply/reply/session.sender-meta.test.ts deleted file mode 100644 index 455cfbb11..000000000 --- a/src/auto-reply/reply/session.sender-meta.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/src/auto-reply/reply/streaming-directives.test.ts b/src/auto-reply/reply/streaming-directives.test.ts deleted file mode 100644 index 02d32ded8..000000000 --- a/src/auto-reply/reply/streaming-directives.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/auto-reply/reply/typing-mode.test.ts b/src/auto-reply/reply/typing-mode.test.ts deleted file mode 100644 index 064e58adf..000000000 --- a/src/auto-reply/reply/typing-mode.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/auto-reply/reply/typing.test.ts b/src/auto-reply/reply/typing.test.ts index da7033162..06e9003c5 100644 --- a/src/auto-reply/reply/typing.test.ts +++ b/src/auto-reply/reply/typing.test.ts @@ -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(); + }); +}); diff --git a/src/auto-reply/templating.test.ts b/src/auto-reply/templating.test.ts deleted file mode 100644 index a4be64f4b..000000000 --- a/src/auto-reply/templating.test.ts +++ /dev/null @@ -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; - 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).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="); - }); -});