Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
862e89e683 | ||
|
|
03384e4b80 |
No files matched your search
@@ -9,9 +9,7 @@ Docs: https://docs.clawd.bot
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
|
||||
### Fixes
|
||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||
- Gateway/Linux: include user + env-configured bin dirs in systemd PATH and align service audit checks. (#1512) Thanks @robbyczgw-cla.
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
@@ -222,12 +222,6 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb
|
||||
return res;
|
||||
}
|
||||
|
||||
const flushAsync = async () => {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
|
||||
describe("BlueBubbles webhook monitor", () => {
|
||||
let unregister: () => void;
|
||||
|
||||
@@ -514,7 +508,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -562,7 +556,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||
@@ -609,7 +603,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
// Wait for async processing
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
@@ -648,7 +642,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
@@ -689,7 +683,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
@@ -732,7 +726,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
||||
// Should not send pairing reply since created=false
|
||||
@@ -773,7 +767,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
@@ -810,7 +804,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -850,7 +844,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
@@ -888,7 +882,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -927,7 +921,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -966,7 +960,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1007,7 +1001,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1048,7 +1042,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1086,7 +1080,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1129,7 +1123,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1175,7 +1169,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1221,7 +1215,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const originalRes = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Only assert the reply message behavior below.
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
@@ -1245,7 +1239,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const replyRes = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1291,7 +1285,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1339,7 +1333,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -1392,7 +1386,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should process even without mention because it's an authorized control command
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
@@ -1435,7 +1429,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1478,7 +1472,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(markBlueBubblesChatRead).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1519,7 +1513,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1562,7 +1556,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should call typing start when reply flow triggers it.
|
||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||
@@ -1612,7 +1606,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
@@ -1657,7 +1651,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
@@ -1705,7 +1699,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
@@ -1750,7 +1744,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reaction added"),
|
||||
@@ -1790,7 +1784,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reaction removed"),
|
||||
@@ -1830,7 +1824,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1868,7 +1862,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("👍"),
|
||||
@@ -1909,7 +1903,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1949,7 +1943,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// The short ID "1" should resolve back to the full UUID
|
||||
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
||||
@@ -2001,7 +1995,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
|
||||
@@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
describe("agents_list", () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ vi.mock("../media/image-ops.js", () => ({
|
||||
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
|
||||
}));
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
describe("nodes camera_snap", () => {
|
||||
|
||||
@@ -75,7 +75,6 @@ vi.mock("../infra/provider-usage.js", () => ({
|
||||
formatUsageSummaryLine: () => null,
|
||||
}));
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
describe("session_status tool", () => {
|
||||
|
||||
@@ -4,6 +4,9 @@ const callGatewayMock = vi.fn();
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
vi.mock("../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
@@ -20,7 +23,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
|
||||
|
||||
-1
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
-1
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
-1
@@ -22,7 +22,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
-1
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
-1
@@ -22,7 +22,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
const defaultTools = createClawdbotCodingTools();
|
||||
|
||||
-1
@@ -3,7 +3,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
const defaultTools = createClawdbotCodingTools();
|
||||
|
||||
-1
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
|
||||
-1
@@ -3,7 +3,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { createSandboxedReadTool } from "./pi-tools.read.js";
|
||||
|
||||
@@ -1,34 +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 { acquireSessionWriteLock } from "./session-write-lock.js";
|
||||
|
||||
describe("acquireSessionWriteLock", () => {
|
||||
it("reuses locks across symlinked session paths", async () => {
|
||||
if (process.platform === "win32") {
|
||||
expect(true).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||
try {
|
||||
const realDir = path.join(root, "real");
|
||||
const linkDir = path.join(root, "link");
|
||||
await fs.mkdir(realDir, { recursive: true });
|
||||
await fs.symlink(realDir, linkDir);
|
||||
|
||||
const sessionReal = path.join(realDir, "sessions.json");
|
||||
const sessionLink = path.join(linkDir, "sessions.json");
|
||||
|
||||
const lockA = await acquireSessionWriteLock({ sessionFile: sessionReal, timeoutMs: 500 });
|
||||
const lockB = await acquireSessionWriteLock({ sessionFile: sessionLink, timeoutMs: 500 });
|
||||
|
||||
await lockB.release();
|
||||
await lockA.release();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,28 +45,20 @@ export async function acquireSessionWriteLock(params: {
|
||||
}> {
|
||||
const timeoutMs = params.timeoutMs ?? 10_000;
|
||||
const staleMs = params.staleMs ?? 30 * 60 * 1000;
|
||||
const sessionFile = path.resolve(params.sessionFile);
|
||||
const sessionDir = path.dirname(sessionFile);
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
let normalizedDir = sessionDir;
|
||||
try {
|
||||
normalizedDir = await fs.realpath(sessionDir);
|
||||
} catch {
|
||||
// Fall back to the resolved path if realpath fails (permissions, transient FS).
|
||||
}
|
||||
const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile));
|
||||
const lockPath = `${normalizedSessionFile}.lock`;
|
||||
const sessionFile = params.sessionFile;
|
||||
const lockPath = `${sessionFile}.lock`;
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||
|
||||
const held = HELD_LOCKS.get(normalizedSessionFile);
|
||||
const held = HELD_LOCKS.get(sessionFile);
|
||||
if (held) {
|
||||
held.count += 1;
|
||||
return {
|
||||
release: async () => {
|
||||
const current = HELD_LOCKS.get(normalizedSessionFile);
|
||||
const current = HELD_LOCKS.get(sessionFile);
|
||||
if (!current) return;
|
||||
current.count -= 1;
|
||||
if (current.count > 0) return;
|
||||
HELD_LOCKS.delete(normalizedSessionFile);
|
||||
HELD_LOCKS.delete(sessionFile);
|
||||
await current.handle.close();
|
||||
await fs.rm(current.lockPath, { force: true });
|
||||
},
|
||||
@@ -83,14 +75,14 @@ export async function acquireSessionWriteLock(params: {
|
||||
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath });
|
||||
HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath });
|
||||
return {
|
||||
release: async () => {
|
||||
const current = HELD_LOCKS.get(normalizedSessionFile);
|
||||
const current = HELD_LOCKS.get(sessionFile);
|
||||
if (!current) return;
|
||||
current.count -= 1;
|
||||
if (current.count > 0) return;
|
||||
HELD_LOCKS.delete(normalizedSessionFile);
|
||||
HELD_LOCKS.delete(sessionFile);
|
||||
await current.handle.close();
|
||||
await fs.rm(current.lockPath, { force: true });
|
||||
},
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
const stubTool = (name: string) => ({
|
||||
name,
|
||||
description: `${name} stub`,
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("../tools/image-tool.js", () => ({
|
||||
createImageTool: () => stubTool("image"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/web-tools.js", () => ({
|
||||
createWebSearchTool: () => null,
|
||||
createWebFetchTool: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
getPluginToolMeta: () => undefined,
|
||||
}));
|
||||
@@ -1,30 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
const stubTool = (name: string) => ({
|
||||
name,
|
||||
description: `${name} stub`,
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("../tools/browser-tool.js", () => ({
|
||||
createBrowserTool: () => stubTool("browser"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/canvas-tool.js", () => ({
|
||||
createCanvasTool: () => stubTool("canvas"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/image-tool.js", () => ({
|
||||
createImageTool: () => stubTool("image"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/web-tools.js", () => ({
|
||||
createWebSearchTool: () => null,
|
||||
createWebFetchTool: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
getPluginToolMeta: () => undefined,
|
||||
}));
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveCommandAuthorization } from "./command-auth.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
|
||||
describe("resolveCommandAuthorization", () => {
|
||||
it("falls back from empty SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: "",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back from whitespace SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: " ",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+999"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: " ",
|
||||
SenderE164: " ",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+999");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back from un-normalizable SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: "wat",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:120363401234567890@g.us",
|
||||
SenderId: "123@lid",
|
||||
SenderE164: "+41796666864",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+41796666864");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,10 @@
|
||||
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 type { MsgContext } from "./templating.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
@@ -18,123 +14,6 @@ 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({
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
serializeCommandArgs,
|
||||
} from "./commands-registry.js";
|
||||
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
||||
|
||||
describe("commands registry args", () => {
|
||||
it("parses positional args and captureRemaining", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "debug",
|
||||
description: "debug",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{ name: "action", description: "action", type: "string" },
|
||||
{ name: "path", description: "path", type: "string" },
|
||||
{ name: "value", description: "value", type: "string", captureRemaining: true },
|
||||
],
|
||||
};
|
||||
|
||||
const args = parseCommandArgs(command, "set foo bar baz");
|
||||
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
|
||||
});
|
||||
|
||||
it("serializes args via raw first, then values", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
description: "model",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
|
||||
};
|
||||
|
||||
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
|
||||
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"gpt-5.2-codex",
|
||||
);
|
||||
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"/model gpt-5.2-codex",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves auto arg menus when missing a choice arg", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "usage",
|
||||
description: "usage",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("mode");
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
||||
});
|
||||
|
||||
it("does not show menus when arg already provided", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "usage",
|
||||
description: "usage",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { values: { mode: "tokens" } },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves function-based choices with a default provider/model context", () => {
|
||||
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
|
||||
null;
|
||||
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "think",
|
||||
description: "think",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "level",
|
||||
description: "level",
|
||||
type: "string",
|
||||
choices: ({ provider, model, command, arg }) => {
|
||||
seen = { provider, model, commandKey: command.key, argName: arg.name };
|
||||
return ["low", "high"];
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("level");
|
||||
expect(menu?.choices).toEqual(["low", "high"]);
|
||||
expect(seen?.commandKey).toBe("think");
|
||||
expect(seen?.argName).toBe("level");
|
||||
expect(seen?.provider).toBeTruthy();
|
||||
expect(seen?.model).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show menus when args were provided as raw text only", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "usage",
|
||||
description: "usage",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "none",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { raw: "on" },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,14 @@ 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";
|
||||
|
||||
@@ -159,150 +154,3 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createInboundDebouncer } from "./inbound-debounce.js";
|
||||
|
||||
describe("createInboundDebouncer", () => {
|
||||
it("debounces and combines items", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
||||
debounceMs: 10,
|
||||
buildKey: (item) => item.key,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1" });
|
||||
await debouncer.enqueue({ key: "a", id: "2" });
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(calls).toEqual([["1", "2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("flushes buffered items before non-debounced item", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
||||
debounceMs: 50,
|
||||
buildKey: (item) => item.key,
|
||||
shouldDebounce: (item) => item.debounce,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
||||
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
||||
|
||||
expect(calls).toEqual([["1"], ["2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,402 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { GroupKeyResolution } from "../config/sessions.js";
|
||||
import { createInboundDebouncer } from "./inbound-debounce.js";
|
||||
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";
|
||||
import { finalizeInboundContext } from "./reply/inbound-context.js";
|
||||
import {
|
||||
buildInboundDedupeKey,
|
||||
resetInboundDedupe,
|
||||
shouldSkipDuplicateInbound,
|
||||
} from "./reply/inbound-dedupe.js";
|
||||
import { formatInboundBodyWithSenderMeta } from "./reply/inbound-sender-meta.js";
|
||||
import { normalizeInboundTextNewlines } from "./reply/inbound-text.js";
|
||||
import { resolveGroupRequireMention } from "./reply/groups.js";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
normalizeMentionText,
|
||||
} from "./reply/mentions.js";
|
||||
import { initSessionState } from "./reply/session.js";
|
||||
|
||||
describe("applyTemplate", () => {
|
||||
it("renders primitive values", () => {
|
||||
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
|
||||
const overrides = ctx as Record<string, unknown>;
|
||||
overrides.MessageSid = 42;
|
||||
overrides.IsNewSession = true;
|
||||
|
||||
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
|
||||
});
|
||||
|
||||
it("renders arrays of primitives", () => {
|
||||
const ctx = { MediaPaths: ["a"] } as TemplateContext;
|
||||
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
|
||||
|
||||
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
|
||||
});
|
||||
|
||||
it("drops object values", () => {
|
||||
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
|
||||
|
||||
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
|
||||
});
|
||||
|
||||
it("renders missing placeholders as empty", () => {
|
||||
const ctx: TemplateContext = {};
|
||||
|
||||
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInboundTextNewlines", () => {
|
||||
it("keeps real newlines", () => {
|
||||
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("normalizes CRLF/CR to LF", () => {
|
||||
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
|
||||
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("decodes literal \\n to newlines when no real newlines exist", () => {
|
||||
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("finalizeInboundContext", () => {
|
||||
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
|
||||
const ctx: MsgContext = {
|
||||
Body: "a\\nb\r\nc",
|
||||
RawBody: "raw\\nline",
|
||||
ChatType: "channel",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
GroupSubject: "Test",
|
||||
};
|
||||
|
||||
const out = finalizeInboundContext(ctx);
|
||||
expect(out.Body).toBe("a\nb\nc");
|
||||
expect(out.RawBody).toBe("raw\nline");
|
||||
expect(out.BodyForAgent).toBe("a\nb\nc");
|
||||
expect(out.BodyForCommands).toBe("raw\nline");
|
||||
expect(out.CommandAuthorized).toBe(false);
|
||||
expect(out.ChatType).toBe("channel");
|
||||
expect(out.ConversationLabel).toContain("Test");
|
||||
});
|
||||
|
||||
it("can force BodyForCommands to follow updated CommandBody", () => {
|
||||
const ctx: MsgContext = {
|
||||
Body: "base",
|
||||
BodyForCommands: "<media:audio>",
|
||||
CommandBody: "say hi",
|
||||
From: "signal:+15550001111",
|
||||
ChatType: "direct",
|
||||
};
|
||||
|
||||
finalizeInboundContext(ctx, { forceBodyForCommands: true });
|
||||
expect(ctx.BodyForCommands).toBe("say hi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInboundBodyWithSenderMeta", () => {
|
||||
it("does nothing for direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
|
||||
});
|
||||
|
||||
it("appends a sender meta line for non-direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers SenderE164 in the label when present", () => {
|
||||
const ctx: MsgContext = {
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderId: "bob@s.whatsapp.net",
|
||||
SenderE164: "+222",
|
||||
};
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||
"[X] hi\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends with a real newline even if the body contains literal \\n", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
|
||||
"[X] one\\n[X] two\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate a sender meta line when one is already present", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not append when the body already includes a sender prefix", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
|
||||
});
|
||||
|
||||
it("does not append when the sender prefix follows an envelope header", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
|
||||
"[Signal Group] Alice (A1): hi",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inbound dedupe", () => {
|
||||
it("builds a stable key when MessageSid is present", () => {
|
||||
const ctx: MsgContext = {
|
||||
Provider: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:123",
|
||||
MessageSid: "42",
|
||||
};
|
||||
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
|
||||
});
|
||||
|
||||
it("skips duplicates with the same key", () => {
|
||||
resetInboundDedupe();
|
||||
const ctx: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+1555",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
|
||||
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not dedupe when the peer changes", () => {
|
||||
resetInboundDedupe();
|
||||
const base: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not dedupe across session keys", () => {
|
||||
resetInboundDedupe();
|
||||
const base: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+1555",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInboundDebouncer", () => {
|
||||
it("debounces and combines items", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
||||
debounceMs: 10,
|
||||
buildKey: (item) => item.key,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1" });
|
||||
await debouncer.enqueue({ key: "a", id: "2" });
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(calls).toEqual([["1", "2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("flushes buffered items before non-debounced item", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
||||
debounceMs: 50,
|
||||
buildKey: (item) => item.key,
|
||||
shouldDebounce: (item) => item.debounce,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
||||
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
||||
|
||||
expect(calls).toEqual([["1"], ["2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSessionState sender meta", () => {
|
||||
it("injects sender meta into BodyStripped for group chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp 123@g.us] ping",
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SenderId: "222@s.whatsapp.net",
|
||||
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("does not inject sender meta for direct chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp +1] ping",
|
||||
ChatType: "direct",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SessionKey: "agent:main:whatsapp:dm:+222",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mention helpers", () => {
|
||||
it("builds regexes and skips invalid patterns", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
|
||||
},
|
||||
});
|
||||
expect(regexes).toHaveLength(1);
|
||||
expect(regexes[0]?.test("clawd")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes zero-width characters", () => {
|
||||
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
|
||||
});
|
||||
|
||||
it("matches patterns case-insensitively", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
|
||||
});
|
||||
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses per-agent mention patterns when configured", () => {
|
||||
const regexes = buildMentionRegexes(
|
||||
{
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"work",
|
||||
);
|
||||
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
|
||||
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGroupRequireMention", () => {
|
||||
it("respects Discord guild/channel requireMention settings", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
guilds: {
|
||||
"145": {
|
||||
requireMention: false,
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Provider: "discord",
|
||||
From: "discord:group:123",
|
||||
GroupChannel: "#general",
|
||||
GroupSpace: "145",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
channel: "discord",
|
||||
id: "123",
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
|
||||
it("respects Slack channel requireMention settings", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
channels: {
|
||||
C123: { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Provider: "slack",
|
||||
From: "slack:channel:C123",
|
||||
GroupSubject: "#general",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
channel: "slack",
|
||||
id: "C123",
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+136
-41
@@ -2,79 +2,174 @@ 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";
|
||||
|
||||
const sandboxMocks = vi.hoisted(() => ({
|
||||
ensureSandboxWorkspaceForSession: vi.fn(),
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/sandbox.js", () => sandboxMocks);
|
||||
const usageMocks = vi.hoisted(() => ({
|
||||
loadProviderUsageSummary: vi.fn().mockResolvedValue({
|
||||
updatedAt: 0,
|
||||
providers: [],
|
||||
}),
|
||||
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
|
||||
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/provider-usage.js", () => usageMocks);
|
||||
|
||||
const modelCatalogMocks = vi.hoisted(() => ({
|
||||
loadModelCatalog: vi.fn().mockResolvedValue([
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-5",
|
||||
name: "Claude Opus 4.5",
|
||||
contextWindow: 200000,
|
||||
},
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "anthropic/claude-opus-4-5",
|
||||
name: "Claude Opus 4.5 (OpenRouter)",
|
||||
contextWindow: 200000,
|
||||
},
|
||||
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
|
||||
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" },
|
||||
{ provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
]),
|
||||
resetModelCatalogCacheForTest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
|
||||
|
||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
||||
import { stageSandboxMedia } from "./reply/stage-sandbox-media.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);
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(async (home) => await fn(home), { prefix: "clawdbot-triggers-" });
|
||||
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") },
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("stageSandboxMedia", () => {
|
||||
it("stages inbound media into the sandbox workspace", async () => {
|
||||
describe("trigger handling", () => {
|
||||
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, 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");
|
||||
|
||||
const sandboxDir = join(home, "sandboxes", "session");
|
||||
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
|
||||
workspaceDir: sandboxDir,
|
||||
containerWorkdir: "/work",
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const ctx: MsgContext = {
|
||||
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 = {
|
||||
Body: "hi",
|
||||
From: "whatsapp:group:demo",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
ChatType: "group" as const,
|
||||
Provider: "whatsapp" as const,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: "image/jpeg",
|
||||
MediaUrl: mediaPath,
|
||||
};
|
||||
const sessionCtx: TemplateContext = { ...ctx };
|
||||
|
||||
await stageSandboxMedia({
|
||||
ctx,
|
||||
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"),
|
||||
});
|
||||
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(ctx.MediaPath).toBe(stagedPath);
|
||||
expect(sessionCtx.MediaPath).toBe(stagedPath);
|
||||
expect(ctx.MediaUrl).toBe(stagedPath);
|
||||
expect(sessionCtx.MediaUrl).toBe(stagedPath);
|
||||
expect(prompt).toContain(stagedPath);
|
||||
expect(prompt).not.toContain(mediaPath);
|
||||
|
||||
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(mediaPath));
|
||||
const sessionKey = resolveSessionKey(
|
||||
cfg.session?.scope ?? "per-sender",
|
||||
ctx,
|
||||
cfg.session?.mainKey,
|
||||
);
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const sandbox = await ensureSandboxWorkspaceForSession({
|
||||
config: cfg,
|
||||
sessionKey,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
});
|
||||
expect(sandbox).not.toBeNull();
|
||||
if (!sandbox) {
|
||||
throw new Error("Expected sandbox to be set");
|
||||
}
|
||||
const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath));
|
||||
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,25 @@
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
+4
-98
@@ -5,47 +5,6 @@ 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" },
|
||||
@@ -87,70 +46,17 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "telegram",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
contextTokens: 16000,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleCommands /allowlist", () => {
|
||||
it("lists config + store allowFrom entries", async () => {
|
||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist list dm", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Channel: telegram");
|
||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||
});
|
||||
|
||||
it("adds entries to config and pairing store", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: ["123", "789"],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist add dm 789", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||
}),
|
||||
);
|
||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
});
|
||||
expect(result.reply?.text).toContain("DM allowlist added");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/models command", () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
// allowlist is empty => allowAny, but still okay for listing
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
} as unknown as ClawdbotConfig;
|
||||
|
||||
@@ -1,125 +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 { 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
+2
-54
@@ -5,12 +5,8 @@ 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 for directive handling persistence.
|
||||
// Mock dependencies
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentConfig: vi.fn(() => ({})),
|
||||
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
||||
@@ -40,55 +36,6 @@ 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 = [
|
||||
@@ -159,6 +106,7 @@ 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");
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
-77
@@ -1,9 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -61,79 +57,6 @@ 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 () => {});
|
||||
@@ -1,185 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,6 @@ export async function getReplyFromConfig(
|
||||
opts?: GetReplyOptions,
|
||||
configOverride?: ClawdbotConfig,
|
||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1";
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const targetSessionKey =
|
||||
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
|
||||
@@ -63,7 +62,7 @@ export async function getReplyFromConfig(
|
||||
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDirRaw,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
@@ -82,14 +81,12 @@ export async function getReplyFromConfig(
|
||||
|
||||
const finalized = finalizeInboundContext(ctx);
|
||||
|
||||
if (!isFastTestEnv) {
|
||||
await applyMediaUnderstanding({
|
||||
ctx: finalized,
|
||||
cfg,
|
||||
agentDir,
|
||||
activeModel: { provider, model },
|
||||
});
|
||||
}
|
||||
await applyMediaUnderstanding({
|
||||
ctx: finalized,
|
||||
cfg,
|
||||
agentDir,
|
||||
activeModel: { provider, model },
|
||||
});
|
||||
|
||||
const commandAuthorized = finalized.CommandAuthorized;
|
||||
resolveCommandAuthorization({
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
-96
@@ -1,11 +1,6 @@
|
||||
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 () => {
|
||||
@@ -155,94 +150,3 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
+9
-135
@@ -2,21 +2,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } 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> {
|
||||
@@ -65,6 +54,7 @@ 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",
|
||||
@@ -86,6 +76,7 @@ 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);
|
||||
@@ -108,6 +99,7 @@ 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",
|
||||
@@ -119,7 +111,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
SenderName: "OtherPerson",
|
||||
SenderE164: "+1555123456",
|
||||
SenderE164: "+1555123456", // Different sender (not authorized)
|
||||
SenderId: "1555123456:0@s.whatsapp.net",
|
||||
};
|
||||
|
||||
@@ -129,8 +121,9 @@ 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);
|
||||
expect(result.sessionId).toBe(existingSessionId); // Session should NOT change
|
||||
expect(result.isNewSession).toBe(false);
|
||||
});
|
||||
|
||||
@@ -150,7 +143,9 @@ 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",
|
||||
@@ -256,124 +251,3 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
|
||||
|
||||
describe("resolveTypingMode", () => {
|
||||
it("defaults to instant for direct chats", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("instant");
|
||||
});
|
||||
|
||||
it("defaults to message for group chats without mentions", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("message");
|
||||
});
|
||||
|
||||
it("defaults to instant for mentioned group chats", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("instant");
|
||||
});
|
||||
|
||||
it("honors configured mode across contexts", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "thinking",
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("thinking");
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "message",
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("message");
|
||||
});
|
||||
|
||||
it("forces never for heartbeat runs", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "instant",
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: true,
|
||||
}),
|
||||
).toBe("never");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTypingSignaler", () => {
|
||||
it("signals immediately for instant mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "instant",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalRunStart();
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals on text for message mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hello");
|
||||
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals on message start for message mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalMessageStart();
|
||||
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
await signaler.signalTextDelta("hello");
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("signals on reasoning for thinking mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "thinking",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalReasoningDelta();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
await signaler.signalTextDelta("hi");
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes ttl on text for thinking mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "thinking",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hi");
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts typing on tool start before text", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalToolStart();
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes ttl on tool start when active after text", async () => {
|
||||
const typing = createMockTypingController({
|
||||
isActive: vi.fn(() => true),
|
||||
});
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hello");
|
||||
typing.startTypingLoop.mockClear();
|
||||
typing.startTypingOnText.mockClear();
|
||||
typing.refreshTypingTtl.mockClear();
|
||||
await signaler.signalToolStart();
|
||||
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses typing when disabled", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "instant",
|
||||
isHeartbeat: true,
|
||||
});
|
||||
|
||||
await signaler.signalRunStart();
|
||||
await signaler.signalTextDelta("hi");
|
||||
await signaler.signalReasoningDelta();
|
||||
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
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", () => {
|
||||
@@ -93,192 +91,3 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyTemplate, type TemplateContext } from "./templating.js";
|
||||
|
||||
describe("applyTemplate", () => {
|
||||
it("renders primitive values", () => {
|
||||
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
|
||||
const overrides = ctx as Record<string, unknown>;
|
||||
overrides.MessageSid = 42;
|
||||
overrides.IsNewSession = true;
|
||||
|
||||
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
|
||||
});
|
||||
|
||||
it("renders arrays of primitives", () => {
|
||||
const ctx = { MediaPaths: ["a"] } as TemplateContext;
|
||||
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
|
||||
|
||||
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
|
||||
});
|
||||
|
||||
it("drops object values", () => {
|
||||
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
|
||||
|
||||
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
|
||||
});
|
||||
|
||||
it("renders missing placeholders as empty", () => {
|
||||
const ctx: TemplateContext = {};
|
||||
|
||||
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
|
||||
});
|
||||
});
|
||||
+134
-54
@@ -122,62 +122,142 @@ describe("cli program (smoke)", () => {
|
||||
expect(setupCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes auth api keys to onboard", async () => {
|
||||
const cases = [
|
||||
{
|
||||
it("passes opencode-zen api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"opencode-zen",
|
||||
"--opencode-zen-api-key",
|
||||
"sk-opencode-zen-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "opencode-zen",
|
||||
flag: "--opencode-zen-api-key",
|
||||
key: "sk-opencode-zen-test",
|
||||
field: "opencodeZenApiKey",
|
||||
},
|
||||
{
|
||||
authChoice: "openrouter-api-key",
|
||||
flag: "--openrouter-api-key",
|
||||
key: "sk-openrouter-test",
|
||||
field: "openrouterApiKey",
|
||||
},
|
||||
{
|
||||
authChoice: "moonshot-api-key",
|
||||
flag: "--moonshot-api-key",
|
||||
key: "sk-moonshot-test",
|
||||
field: "moonshotApiKey",
|
||||
},
|
||||
{
|
||||
authChoice: "kimi-code-api-key",
|
||||
flag: "--kimi-code-api-key",
|
||||
key: "sk-kimi-code-test",
|
||||
field: "kimiCodeApiKey",
|
||||
},
|
||||
{
|
||||
authChoice: "synthetic-api-key",
|
||||
flag: "--synthetic-api-key",
|
||||
key: "sk-synthetic-test",
|
||||
field: "syntheticApiKey",
|
||||
},
|
||||
{
|
||||
authChoice: "zai-api-key",
|
||||
flag: "--zai-api-key",
|
||||
key: "sk-zai-test",
|
||||
field: "zaiApiKey",
|
||||
},
|
||||
] as const;
|
||||
opencodeZenApiKey: "sk-opencode-zen-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
for (const entry of cases) {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
["onboard", "--non-interactive", "--auth-choice", entry.authChoice, entry.flag, entry.key],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: entry.authChoice,
|
||||
[entry.field]: entry.key,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
onboardCommand.mockClear();
|
||||
}
|
||||
it("passes openrouter api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"openrouter-api-key",
|
||||
"--openrouter-api-key",
|
||||
"sk-openrouter-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "openrouter-api-key",
|
||||
openrouterApiKey: "sk-openrouter-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes moonshot api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"moonshot-api-key",
|
||||
"--moonshot-api-key",
|
||||
"sk-moonshot-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "moonshot-api-key",
|
||||
moonshotApiKey: "sk-moonshot-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes kimi code api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"kimi-code-api-key",
|
||||
"--kimi-code-api-key",
|
||||
"sk-kimi-code-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "kimi-code-api-key",
|
||||
kimiCodeApiKey: "sk-kimi-code-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes synthetic api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"synthetic-api-key",
|
||||
"--synthetic-api-key",
|
||||
"sk-synthetic-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "synthetic-api-key",
|
||||
syntheticApiKey: "sk-synthetic-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes zai api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"zai-api-key",
|
||||
"--zai-api-key",
|
||||
"sk-zai-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "zai-api-key",
|
||||
zaiApiKey: "sk-zai-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs channels login", async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMinimalServicePath } from "./service-env.js";
|
||||
import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js";
|
||||
|
||||
describe("auditGatewayServiceConfig", () => {
|
||||
@@ -40,24 +39,4 @@ describe("auditGatewayServiceConfig", () => {
|
||||
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts Linux minimal PATH with user directories", async () => {
|
||||
const env = { HOME: "/home/testuser", PNPM_HOME: "/opt/pnpm" };
|
||||
const minimalPath = buildMinimalServicePath({ platform: "linux", env });
|
||||
const audit = await auditGatewayServiceConfig({
|
||||
env,
|
||||
platform: "linux",
|
||||
command: {
|
||||
programArguments: ["/usr/bin/node", "gateway"],
|
||||
environment: { PATH: minimalPath },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathNonMinimal),
|
||||
).toBe(false);
|
||||
expect(
|
||||
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -206,7 +206,6 @@ function normalizePathEntry(entry: string, platform: NodeJS.Platform): string {
|
||||
function auditGatewayServicePath(
|
||||
command: GatewayServiceCommand,
|
||||
issues: ServiceConfigIssue[],
|
||||
env: Record<string, string | undefined>,
|
||||
platform: NodeJS.Platform,
|
||||
) {
|
||||
if (platform === "win32") return;
|
||||
@@ -220,13 +219,12 @@ function auditGatewayServicePath(
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = getMinimalServicePathParts({ platform, home: env.HOME, env });
|
||||
const expected = getMinimalServicePathParts({ platform });
|
||||
const parts = servicePath
|
||||
.split(getPathModule(platform).delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const normalizedParts = parts.map((entry) => normalizePathEntry(entry, platform));
|
||||
const normalizedExpected = new Set(expected.map((entry) => normalizePathEntry(entry, platform)));
|
||||
const missing = expected.filter((entry) => {
|
||||
const normalized = normalizePathEntry(entry, platform);
|
||||
return !normalizedParts.includes(normalized);
|
||||
@@ -241,9 +239,6 @@ function auditGatewayServicePath(
|
||||
|
||||
const nonMinimal = parts.filter((entry) => {
|
||||
const normalized = normalizePathEntry(entry, platform);
|
||||
if (normalizedExpected.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalized.includes("/.nvm/") ||
|
||||
normalized.includes("/.fnm/") ||
|
||||
@@ -320,7 +315,7 @@ export async function auditGatewayServiceConfig(params: {
|
||||
const platform = params.platform ?? process.platform;
|
||||
|
||||
auditGatewayCommand(params.command?.programArguments, issues);
|
||||
auditGatewayServicePath(params.command, issues, params.env, platform);
|
||||
auditGatewayServicePath(params.command, issues, platform);
|
||||
await auditGatewayRuntime(params.env, params.command, issues, platform);
|
||||
|
||||
if (platform === "linux") {
|
||||
|
||||
@@ -4,122 +4,8 @@ import {
|
||||
buildMinimalServicePath,
|
||||
buildNodeServiceEnvironment,
|
||||
buildServiceEnvironment,
|
||||
getMinimalServicePathParts,
|
||||
} from "./service-env.js";
|
||||
|
||||
describe("getMinimalServicePathParts - Linux user directories", () => {
|
||||
it("includes user bin directories when HOME is set on Linux", () => {
|
||||
const result = getMinimalServicePathParts({
|
||||
platform: "linux",
|
||||
home: "/home/testuser",
|
||||
});
|
||||
|
||||
// Should include all common user bin directories
|
||||
expect(result).toContain("/home/testuser/.local/bin");
|
||||
expect(result).toContain("/home/testuser/.npm-global/bin");
|
||||
expect(result).toContain("/home/testuser/bin");
|
||||
expect(result).toContain("/home/testuser/.nvm/current/bin");
|
||||
expect(result).toContain("/home/testuser/.fnm/current/bin");
|
||||
expect(result).toContain("/home/testuser/.volta/bin");
|
||||
expect(result).toContain("/home/testuser/.asdf/shims");
|
||||
expect(result).toContain("/home/testuser/.local/share/pnpm");
|
||||
expect(result).toContain("/home/testuser/.bun/bin");
|
||||
});
|
||||
|
||||
it("excludes user bin directories when HOME is undefined on Linux", () => {
|
||||
const result = getMinimalServicePathParts({
|
||||
platform: "linux",
|
||||
home: undefined,
|
||||
});
|
||||
|
||||
// Should only include system directories
|
||||
expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]);
|
||||
|
||||
// Should not include any user-specific paths
|
||||
expect(result.some((p) => p.includes(".local"))).toBe(false);
|
||||
expect(result.some((p) => p.includes(".npm-global"))).toBe(false);
|
||||
expect(result.some((p) => p.includes(".nvm"))).toBe(false);
|
||||
});
|
||||
|
||||
it("places user directories before system directories on Linux", () => {
|
||||
const result = getMinimalServicePathParts({
|
||||
platform: "linux",
|
||||
home: "/home/testuser",
|
||||
});
|
||||
|
||||
const userDirIndex = result.indexOf("/home/testuser/.local/bin");
|
||||
const systemDirIndex = result.indexOf("/usr/bin");
|
||||
|
||||
expect(userDirIndex).toBeGreaterThan(-1);
|
||||
expect(systemDirIndex).toBeGreaterThan(-1);
|
||||
expect(userDirIndex).toBeLessThan(systemDirIndex);
|
||||
});
|
||||
|
||||
it("places extraDirs before user directories on Linux", () => {
|
||||
const result = getMinimalServicePathParts({
|
||||
platform: "linux",
|
||||
home: "/home/testuser",
|
||||
extraDirs: ["/custom/bin"],
|
||||
});
|
||||
|
||||
const extraDirIndex = result.indexOf("/custom/bin");
|
||||
const userDirIndex = result.indexOf("/home/testuser/.local/bin");
|
||||
|
||||
expect(extraDirIndex).toBeGreaterThan(-1);
|
||||
expect(userDirIndex).toBeGreaterThan(-1);
|
||||
expect(extraDirIndex).toBeLessThan(userDirIndex);
|
||||
});
|
||||
|
||||
it("includes env-configured bin roots when HOME is set on Linux", () => {
|
||||
const result = getMinimalServicePathParts({
|
||||
platform: "linux",
|
||||
home: "/home/testuser",
|
||||
env: {
|
||||
PNPM_HOME: "/opt/pnpm",
|
||||
NPM_CONFIG_PREFIX: "/opt/npm",
|
||||
BUN_INSTALL: "/opt/bun",
|
||||
VOLTA_HOME: "/opt/volta",
|
||||
ASDF_DATA_DIR: "/opt/asdf",
|
||||
NVM_DIR: "/opt/nvm",
|
||||
FNM_DIR: "/opt/fnm",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContain("/opt/pnpm");
|
||||
expect(result).toContain("/opt/npm/bin");
|
||||
expect(result).toContain("/opt/bun/bin");
|
||||
expect(result).toContain("/opt/volta/bin");
|
||||
expect(result).toContain("/opt/asdf/shims");
|
||||
expect(result).toContain("/opt/nvm/current/bin");
|
||||
expect(result).toContain("/opt/fnm/current/bin");
|
||||
});
|
||||
|
||||
it("does not include Linux user directories on macOS", () => {
|
||||
const result = getMinimalServicePathParts({
|
||||
platform: "darwin",
|
||||
home: "/Users/testuser",
|
||||
});
|
||||
|
||||
// Should not include Linux-specific user dirs even with HOME set
|
||||
expect(result.some((p) => p.includes(".npm-global"))).toBe(false);
|
||||
expect(result.some((p) => p.includes(".nvm"))).toBe(false);
|
||||
|
||||
// Should only include macOS system directories
|
||||
expect(result).toContain("/opt/homebrew/bin");
|
||||
expect(result).toContain("/usr/local/bin");
|
||||
});
|
||||
|
||||
it("does not include Linux user directories on Windows", () => {
|
||||
const result = getMinimalServicePathParts({
|
||||
platform: "win32",
|
||||
home: "C:\\Users\\testuser",
|
||||
});
|
||||
|
||||
// Windows returns empty array (uses existing PATH)
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMinimalServicePath", () => {
|
||||
it("includes Homebrew + system dirs on macOS", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
@@ -140,51 +26,6 @@ describe("buildMinimalServicePath", () => {
|
||||
expect(result).toBe("C:\\\\Windows\\\\System32");
|
||||
});
|
||||
|
||||
it("includes Linux user directories when HOME is set in env", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
platform: "linux",
|
||||
env: { HOME: "/home/alice" },
|
||||
});
|
||||
const parts = result.split(path.delimiter);
|
||||
|
||||
// Verify user directories are included
|
||||
expect(parts).toContain("/home/alice/.local/bin");
|
||||
expect(parts).toContain("/home/alice/.npm-global/bin");
|
||||
expect(parts).toContain("/home/alice/.nvm/current/bin");
|
||||
|
||||
// Verify system directories are also included
|
||||
expect(parts).toContain("/usr/local/bin");
|
||||
expect(parts).toContain("/usr/bin");
|
||||
expect(parts).toContain("/bin");
|
||||
});
|
||||
|
||||
it("excludes Linux user directories when HOME is not in env", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
platform: "linux",
|
||||
env: {},
|
||||
});
|
||||
const parts = result.split(path.delimiter);
|
||||
|
||||
// Should only have system directories
|
||||
expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]);
|
||||
|
||||
// No user-specific paths
|
||||
expect(parts.some((p) => p.includes("home"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ensures user directories come before system directories on Linux", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
platform: "linux",
|
||||
env: { HOME: "/home/bob" },
|
||||
});
|
||||
const parts = result.split(path.delimiter);
|
||||
|
||||
const firstUserDirIdx = parts.indexOf("/home/bob/.local/bin");
|
||||
const firstSystemDirIdx = parts.indexOf("/usr/local/bin");
|
||||
|
||||
expect(firstUserDirIdx).toBeLessThan(firstSystemDirIdx);
|
||||
});
|
||||
|
||||
it("includes extra directories when provided", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
platform: "linux",
|
||||
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
export type MinimalServicePathOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
extraDirs?: string[];
|
||||
home?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
type BuildServicePathOptions = MinimalServicePathOptions & {
|
||||
@@ -35,51 +33,6 @@ function resolveSystemPathDirs(platform: NodeJS.Platform): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve common user bin directories for Linux.
|
||||
* These are paths where npm global installs and node version managers typically place binaries.
|
||||
*/
|
||||
export function resolveLinuxUserBinDirs(
|
||||
home: string | undefined,
|
||||
env?: Record<string, string | undefined>,
|
||||
): string[] {
|
||||
if (!home) return [];
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
const add = (dir: string | undefined) => {
|
||||
if (dir) dirs.push(dir);
|
||||
};
|
||||
const appendSubdir = (base: string | undefined, subdir: string) => {
|
||||
if (!base) return undefined;
|
||||
return base.endsWith(`/${subdir}`) ? base : path.posix.join(base, subdir);
|
||||
};
|
||||
|
||||
// Env-configured bin roots (override defaults when present)
|
||||
add(env?.PNPM_HOME);
|
||||
add(appendSubdir(env?.NPM_CONFIG_PREFIX, "bin"));
|
||||
add(appendSubdir(env?.BUN_INSTALL, "bin"));
|
||||
add(appendSubdir(env?.VOLTA_HOME, "bin"));
|
||||
add(appendSubdir(env?.ASDF_DATA_DIR, "shims"));
|
||||
add(appendSubdir(env?.NVM_DIR, "current/bin"));
|
||||
add(appendSubdir(env?.FNM_DIR, "current/bin"));
|
||||
|
||||
// Common user bin directories
|
||||
dirs.push(`${home}/.local/bin`); // XDG standard, pip, etc.
|
||||
dirs.push(`${home}/.npm-global/bin`); // npm custom prefix (recommended for non-root)
|
||||
dirs.push(`${home}/bin`); // User's personal bin
|
||||
|
||||
// Node version managers
|
||||
dirs.push(`${home}/.nvm/current/bin`); // nvm with current symlink
|
||||
dirs.push(`${home}/.fnm/current/bin`); // fnm
|
||||
dirs.push(`${home}/.volta/bin`); // Volta
|
||||
dirs.push(`${home}/.asdf/shims`); // asdf
|
||||
dirs.push(`${home}/.local/share/pnpm`); // pnpm global bin
|
||||
dirs.push(`${home}/.bun/bin`); // Bun
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
export function getMinimalServicePathParts(options: MinimalServicePathOptions = {}): string[] {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform === "win32") return [];
|
||||
@@ -88,18 +41,12 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions =
|
||||
const extraDirs = options.extraDirs ?? [];
|
||||
const systemDirs = resolveSystemPathDirs(platform);
|
||||
|
||||
// Add Linux user bin directories (npm global, nvm, fnm, volta, etc.)
|
||||
const linuxUserDirs =
|
||||
platform === "linux" ? resolveLinuxUserBinDirs(options.home, options.env) : [];
|
||||
|
||||
const add = (dir: string) => {
|
||||
if (!dir) return;
|
||||
if (!parts.includes(dir)) parts.push(dir);
|
||||
};
|
||||
|
||||
for (const dir of extraDirs) add(dir);
|
||||
// User dirs first so user-installed binaries take precedence
|
||||
for (const dir of linuxUserDirs) add(dir);
|
||||
for (const dir of systemDirs) add(dir);
|
||||
|
||||
return parts;
|
||||
@@ -112,10 +59,7 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}):
|
||||
return env.PATH ?? "";
|
||||
}
|
||||
|
||||
return getMinimalServicePathParts({
|
||||
...options,
|
||||
home: options.home ?? env.HOME,
|
||||
}).join(path.delimiter);
|
||||
return getMinimalServicePathParts(options).join(path.delimiter);
|
||||
}
|
||||
|
||||
export function buildServiceEnvironment(params: {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user