test: consolidate auto-reply unit coverage

This commit is contained in:
Peter Steinberger
2026-01-23 18:32:32 +00:00
parent ace6a42ea6
commit 0d336272f9
38 changed files with 1680 additions and 1903 deletions

View File

@@ -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("");
});
});

View File

@@ -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();
});
});

View File

@@ -1,139 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
};
});
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../pairing/pairing-store.js", async () => {
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
"../../pairing/pairing-store.js",
);
return {
...actual,
readChannelAllowFromStore: readChannelAllowFromStoreMock,
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
};
});
vi.mock("../../channels/plugins/pairing.js", async () => {
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
"../../channels/plugins/pairing.js",
);
return {
...actual,
listPairingChannels: () => ["telegram"],
};
});
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "telegram",
Surface: "telegram",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "telegram",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /allowlist", () => {
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist list dm", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram");
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds entries to config and pairing store", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist add dm 789", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: { telegram: { allowFrom: ["123", "789"] } },
}),
);
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
});
expect(result.reply?.text).toContain("DM allowlist added");
});
});

View File

@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "whatsapp",
Surface: "whatsapp",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "whatsapp",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /config configWrites gating", () => {
it("blocks /config set when channel config writes are disabled", async () => {
const cfg = {
commands: { config: true, text: true },
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
} as ClawdbotConfig;
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Config writes are disabled");
});
});

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { extractMessageText } from "./commands-subagents.js";
import { parseConfigCommand } from "./config-commands.js";
import { parseDebugCommand } from "./debug-commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "whatsapp",
Surface: "whatsapp",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "whatsapp",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("parseConfigCommand", () => {
it("parses show/unset", () => {
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
expect(parseConfigCommand("/config show")).toEqual({
action: "show",
path: undefined,
});
expect(parseConfigCommand("/config show foo.bar")).toEqual({
action: "show",
path: "foo.bar",
});
expect(parseConfigCommand("/config get foo.bar")).toEqual({
action: "show",
path: "foo.bar",
});
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
action: "unset",
path: "foo.bar",
});
});
it("parses set with JSON", () => {
const cmd = parseConfigCommand('/config set foo={"a":1}');
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
});
});
describe("parseDebugCommand", () => {
it("parses show/reset", () => {
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
});
it("parses set with JSON", () => {
const cmd = parseDebugCommand('/debug set foo={"a":1}');
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
});
it("parses unset", () => {
const cmd = parseDebugCommand("/debug unset foo.bar");
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
});
});
describe("extractMessageText", () => {
it("preserves user text that looks like tool call markers", () => {
const message = {
role: "user",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
});
it("sanitizes assistant tool call markers", () => {
const message = {
role: "assistant",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toBe("Here ok");
});
});
describe("handleCommands /config configWrites gating", () => {
it("blocks /config set when channel config writes are disabled", async () => {
const cfg = {
commands: { config: true, text: true },
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
} as ClawdbotConfig;
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Config writes are disabled");
});
});

View File

@@ -5,6 +5,47 @@ import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
};
});
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../pairing/pairing-store.js", async () => {
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
"../../pairing/pairing-store.js",
);
return {
...actual,
readChannelAllowFromStore: readChannelAllowFromStoreMock,
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
};
});
vi.mock("../../channels/plugins/pairing.js", async () => {
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
"../../channels/plugins/pairing.js",
);
return {
...actual,
listPairingChannels: () => ["telegram"],
};
});
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
@@ -46,17 +87,70 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "anthropic",
model: "claude-opus-4-5",
contextTokens: 16000,
provider: "telegram",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /allowlist", () => {
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist list dm", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram");
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds entries to config and pairing store", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist add dm 789", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: { telegram: { allowFrom: ["123", "789"] } },
}),
);
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
});
expect(result.reply?.text).toContain("DM allowlist added");
});
});
describe("/models command", () => {
const cfg = {
commands: { text: true },
// allowlist is empty => allowAny, but still okay for listing
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
} as unknown as ClawdbotConfig;

View File

@@ -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");
});
});

View File

@@ -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 } });
});
});

View File

@@ -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" });
});
});

View File

@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { parseInlineDirectives } from "./directive-handling.js";
import {
maybeHandleModelDirectiveInfo,
resolveModelSelectionFromDirective,
} from "./directive-handling.model.js";
function baseAliasIndex(): ModelAliasIndex {
return { byAlias: new Map(), byKey: new Map() };
}
describe("/model chat UX", () => {
it("shows summary for /model with no args", async () => {
const directives = parseInlineDirectives("/model");
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
const reply = await maybeHandleModelDirectiveInfo({
directives,
cfg,
agentDir: "/tmp/agent",
activeAgentId: "main",
provider: "anthropic",
model: "claude-opus-4-5",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelCatalog: [],
resetModelOverride: false,
});
expect(reply?.text).toContain("Current:");
expect(reply?.text).toContain("Browse: /models");
expect(reply?.text).toContain("Switch: /model <provider/model>");
});
it("auto-applies closest match for typos", () => {
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
const resolved = resolveModelSelectionFromDirective({
directives,
cfg,
agentDir: "/tmp/agent",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
provider: "anthropic",
});
expect(resolved.modelSelection).toEqual({
provider: "anthropic",
model: "claude-opus-4-5",
isDefault: true,
});
expect(resolved.errorText).toBeUndefined();
});
});

View File

@@ -5,8 +5,12 @@ import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { parseInlineDirectives } from "./directive-handling.js";
import { handleDirectiveOnly } from "./directive-handling.impl.js";
import {
maybeHandleModelDirectiveInfo,
resolveModelSelectionFromDirective,
} from "./directive-handling.model.js";
// Mock dependencies
// Mock dependencies for directive handling persistence.
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: vi.fn(() => ({})),
resolveAgentDir: vi.fn(() => "/tmp/agent"),
@@ -36,6 +40,55 @@ function baseConfig(): ClawdbotConfig {
} as unknown as ClawdbotConfig;
}
describe("/model chat UX", () => {
it("shows summary for /model with no args", async () => {
const directives = parseInlineDirectives("/model");
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
const reply = await maybeHandleModelDirectiveInfo({
directives,
cfg,
agentDir: "/tmp/agent",
activeAgentId: "main",
provider: "anthropic",
model: "claude-opus-4-5",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelCatalog: [],
resetModelOverride: false,
});
expect(reply?.text).toContain("Current:");
expect(reply?.text).toContain("Browse: /models");
expect(reply?.text).toContain("Switch: /model <provider/model>");
});
it("auto-applies closest match for typos", () => {
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
const resolved = resolveModelSelectionFromDirective({
directives,
cfg,
agentDir: "/tmp/agent",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
provider: "anthropic",
});
expect(resolved.modelSelection).toEqual({
provider: "anthropic",
model: "claude-opus-4-5",
isDefault: true,
});
expect(resolved.errorText).toBeUndefined();
});
});
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
const allowedModelCatalog = [
@@ -106,7 +159,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
// No model directive = no model message
expect(result?.text ?? "").not.toContain("Model set to");
expect(result?.text ?? "").not.toContain("failed");
});

View File

@@ -1,105 +0,0 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import type { FollowupRun } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
result: await run(provider, model),
provider,
model,
}),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
import { createFollowupRunner } from "./followup-runner.js";
describe("createFollowupRunner compaction", () => {
it("adds verbose auto-compaction notice and tracks count", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
"sessions.json",
);
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore: Record<string, SessionEntry> = {
main: sessionEntry,
};
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: {
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
}) => {
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: false },
});
return { payloads: [{ text: "final" }], meta: {} };
},
);
const runner = createFollowupRunner({
opts: { onBlockReply },
typing: createMockTypingController(),
typingMode: "instant",
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
defaultModel: "anthropic/claude-opus-4-5",
});
const queued = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
messageProvider: "whatsapp",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "on",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as FollowupRun;
await runner(queued);
expect(onBlockReply).toHaveBeenCalled();
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
expect(sessionStore.main.compactionCount).toBe(1);
});
});

View File

@@ -1,5 +1,9 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import type { FollowupRun } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
@@ -57,6 +61,79 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
},
}) as FollowupRun;
describe("createFollowupRunner compaction", () => {
it("adds verbose auto-compaction notice and tracks count", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
"sessions.json",
);
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore: Record<string, SessionEntry> = {
main: sessionEntry,
};
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: {
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
}) => {
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: false },
});
return { payloads: [{ text: "final" }], meta: {} };
},
);
const runner = createFollowupRunner({
opts: { onBlockReply },
typing: createMockTypingController(),
typingMode: "instant",
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
defaultModel: "anthropic/claude-opus-4-5",
});
const queued = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
messageProvider: "whatsapp",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "on",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as FollowupRun;
await runner(queued);
expect(onBlockReply).toHaveBeenCalled();
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
expect(sessionStore.main.compactionCount).toBe(1);
});
});
describe("createFollowupRunner messaging tool dedupe", () => {
it("drops payloads already sent via messaging tool", async () => {
const onBlockReply = vi.fn(async () => {});

View File

@@ -0,0 +1,185 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseAudioTag } from "./audio-tags.js";
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
import { createReplyReferencePlanner } from "./reply-reference.js";
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
describe("parseAudioTag", () => {
it("detects audio_as_voice and strips the tag", () => {
const result = parseAudioTag("Hello [[audio_as_voice]] world");
expect(result.audioAsVoice).toBe(true);
expect(result.hadTag).toBe(true);
expect(result.text).toBe("Hello world");
});
it("returns empty output for missing text", () => {
const result = parseAudioTag(undefined);
expect(result.audioAsVoice).toBe(false);
expect(result.hadTag).toBe(false);
expect(result.text).toBe("");
});
it("removes tag-only messages", () => {
const result = parseAudioTag("[[audio_as_voice]]");
expect(result.audioAsVoice).toBe(true);
expect(result.text).toBe("");
});
});
describe("block reply coalescer", () => {
afterEach(() => {
vi.useRealTimers();
});
it("coalesces chunks within the idle window", async () => {
vi.useFakeTimers();
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "Hello" });
coalescer.enqueue({ text: "world" });
await vi.advanceTimersByTimeAsync(100);
expect(flushes).toEqual(["Hello world"]);
coalescer.stop();
});
it("waits until minChars before idle flush", async () => {
vi.useFakeTimers();
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "short" });
await vi.advanceTimersByTimeAsync(50);
expect(flushes).toEqual([]);
coalescer.enqueue({ text: "message" });
await vi.advanceTimersByTimeAsync(50);
expect(flushes).toEqual(["short message"]);
coalescer.stop();
});
it("flushes buffered text before media payloads", () => {
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
},
});
coalescer.enqueue({ text: "Hello" });
coalescer.enqueue({ text: "world" });
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
void coalescer.flush({ force: true });
expect(flushes[0].text).toBe("Hello world");
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
coalescer.stop();
});
});
describe("createReplyReferencePlanner", () => {
it("disables references when mode is off", () => {
const planner = createReplyReferencePlanner({
replyToMode: "off",
startId: "parent",
});
expect(planner.use()).toBeUndefined();
expect(planner.hasReplied()).toBe(false);
});
it("uses startId once when mode is first", () => {
const planner = createReplyReferencePlanner({
replyToMode: "first",
startId: "parent",
});
expect(planner.use()).toBe("parent");
expect(planner.hasReplied()).toBe(true);
planner.markSent();
expect(planner.use()).toBeUndefined();
});
it("returns startId for every call when mode is all", () => {
const planner = createReplyReferencePlanner({
replyToMode: "all",
startId: "parent",
});
expect(planner.use()).toBe("parent");
expect(planner.use()).toBe("parent");
});
it("prefers existing thread id regardless of mode", () => {
const planner = createReplyReferencePlanner({
replyToMode: "off",
existingId: "thread-1",
startId: "parent",
});
expect(planner.use()).toBe("thread-1");
expect(planner.hasReplied()).toBe(true);
});
it("honors allowReference=false", () => {
const planner = createReplyReferencePlanner({
replyToMode: "all",
startId: "parent",
allowReference: false,
});
expect(planner.use()).toBeUndefined();
expect(planner.hasReplied()).toBe(false);
planner.markSent();
expect(planner.hasReplied()).toBe(true);
});
});
describe("createStreamingDirectiveAccumulator", () => {
it("stashes reply_to_current until a renderable chunk arrives", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
const result = accumulator.consume("Hello");
expect(result?.text).toBe("Hello");
expect(result?.replyToCurrent).toBe(true);
expect(result?.replyToTag).toBe(true);
});
it("handles reply tags split across chunks", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to_")).toBeNull();
const result = accumulator.consume("current]] Yo");
expect(result?.text).toBe("Yo");
expect(result?.replyToCurrent).toBe(true);
expect(result?.replyToTag).toBe(true);
});
it("propagates explicit reply ids across chunks", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
const result = accumulator.consume("Hi");
expect(result?.text).toBe("Hi");
expect(result?.replyToId).toBe("abc-123");
expect(result?.replyToTag).toBe(true);
});
});

View File

@@ -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);
});
});

View File

@@ -1,38 +0,0 @@
import { describe, expect, it } from "vitest";
import type { MsgContext } from "../templating.js";
import { finalizeInboundContext } from "./inbound-context.js";
describe("finalizeInboundContext", () => {
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
const ctx: MsgContext = {
Body: "a\\nb\r\nc",
RawBody: "raw\\nline",
ChatType: "channel",
From: "whatsapp:group:123@g.us",
GroupSubject: "Test",
};
const out = finalizeInboundContext(ctx);
expect(out.Body).toBe("a\nb\nc");
expect(out.RawBody).toBe("raw\nline");
expect(out.BodyForAgent).toBe("a\nb\nc");
expect(out.BodyForCommands).toBe("raw\nline");
expect(out.CommandAuthorized).toBe(false);
expect(out.ChatType).toBe("channel");
expect(out.ConversationLabel).toContain("Test");
});
it("can force BodyForCommands to follow updated CommandBody", () => {
const ctx: MsgContext = {
Body: "base",
BodyForCommands: "<media:audio>",
CommandBody: "say hi",
From: "signal:+15550001111",
ChatType: "direct",
};
finalizeInboundContext(ctx, { forceBodyForCommands: true });
expect(ctx.BodyForCommands).toBe("say hi");
});
});

View File

@@ -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);
});
});

View File

@@ -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",
);
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -2,10 +2,21 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { buildModelAliasIndex } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { initSessionState } from "./session.js";
import { applyResetModelOverride } from "./session-reset-model.js";
import { prependSystemEvents } from "./session-updates.js";
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "minimax", id: "m2.1", name: "M2.1" },
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
]),
}));
describe("initSessionState reset triggers in WhatsApp groups", () => {
async function createStorePath(prefix: string): Promise<string> {
@@ -54,7 +65,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
allowFrom: ["+41796666864"],
});
// Group message context matching what WhatsApp handler creates
const groupMessageCtx = {
Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`,
RawBody: "/new",
@@ -76,7 +86,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
commandAuthorized: true,
});
// The reset should be detected
expect(result.triggerBodyNormalized).toBe("/new");
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
@@ -99,7 +108,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
allowFrom: ["+41796666864"],
});
// Group message from different sender (not in allowFrom)
const groupMessageCtx = {
Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`,
RawBody: "/new",
@@ -111,7 +119,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
Provider: "whatsapp",
Surface: "whatsapp",
SenderName: "OtherPerson",
SenderE164: "+1555123456", // Different sender (not authorized)
SenderE164: "+1555123456",
SenderId: "1555123456:0@s.whatsapp.net",
};
@@ -121,9 +129,8 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
commandAuthorized: true,
});
// Reset should NOT be triggered for unauthorized sender - session ID should stay the same
expect(result.triggerBodyNormalized).toBe("/new");
expect(result.sessionId).toBe(existingSessionId); // Session should NOT change
expect(result.sessionId).toBe(existingSessionId);
expect(result.isNewSession).toBe(false);
});
@@ -143,9 +150,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
});
const groupMessageCtx = {
// Body is wrapped with context prefixes
Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`,
// RawBody is clean
RawBody: "/new",
CommandBody: "/new",
From: "120363406150318674@g.us",
@@ -251,3 +256,124 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
expect(result.isNewSession).toBe(false);
});
});
describe("applyResetModelOverride", () => {
it("selects a model hint and strips it from the body", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.providerOverride).toBe("minimax");
expect(sessionEntry.modelOverride).toBe("m2.1");
expect(sessionCtx.BodyStripped).toBe("summarize");
});
it("clears auth profile overrides when reset applies a model", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
authProfileOverride: "anthropic:default",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.authProfileOverride).toBeUndefined();
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
});
it("skips when resetTriggered is false", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: false,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.providerOverride).toBeUndefined();
expect(sessionEntry.modelOverride).toBeUndefined();
expect(sessionCtx.BodyStripped).toBe("minimax summarize");
});
});
describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events by default", async () => {
vi.useFakeTimers();
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp);
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
const result = await prependSystemEvents({
cfg: {} as ClawdbotConfig,
sessionKey: "agent:main:main",
isMainSession: false,
isNewSession: false,
prefixedBodyBase: "User: hi",
});
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers();
});
});

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});