Merge branch 'main' into commands-list-clean
This commit is contained in:
@@ -17,7 +17,8 @@ export type TextChunkProvider =
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "webchat";
|
||||
| "webchat"
|
||||
| "msteams";
|
||||
|
||||
const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
|
||||
whatsapp: 4000,
|
||||
@@ -27,6 +28,7 @@ const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
|
||||
signal: 4000,
|
||||
imessage: 4000,
|
||||
webchat: 4000,
|
||||
msteams: 4000,
|
||||
};
|
||||
|
||||
export function resolveTextChunkLimit(
|
||||
@@ -70,6 +72,9 @@ export function resolveTextChunkLimit(
|
||||
cfg?.imessage?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "msteams") {
|
||||
return cfg?.msteams?.textChunkLimit;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
if (typeof providerOverride === "number" && providerOverride > 0) {
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("commands registry", () => {
|
||||
expect(detection.regex.test("/status:")).toBe(true);
|
||||
expect(detection.regex.test("/stop")).toBe(true);
|
||||
expect(detection.regex.test("/send:")).toBe(true);
|
||||
expect(detection.regex.test("/debug set foo=bar")).toBe(true);
|
||||
expect(detection.regex.test("/models")).toBe(true);
|
||||
expect(detection.regex.test("/models list")).toBe(true);
|
||||
expect(detection.regex.test("try /status")).toBe(false);
|
||||
|
||||
@@ -33,6 +33,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
|
||||
description: "Show current status.",
|
||||
textAliases: ["/status"],
|
||||
},
|
||||
{
|
||||
key: "debug",
|
||||
nativeName: "debug",
|
||||
description: "Set runtime debug overrides.",
|
||||
textAliases: ["/debug"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
nativeName: "cost",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
@@ -22,15 +21,7 @@ vi.mock("../agents/model-catalog.js", () => ({
|
||||
}));
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-stream-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = base;
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-stream-" });
|
||||
}
|
||||
|
||||
describe("block streaming", () => {
|
||||
@@ -85,9 +76,11 @@ describe("block streaming", () => {
|
||||
onBlockReply,
|
||||
},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@@ -140,9 +133,11 @@ describe("block streaming", () => {
|
||||
onBlockReply,
|
||||
},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
telegram: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@@ -185,9 +180,11 @@ describe("block streaming", () => {
|
||||
onBlockReply,
|
||||
},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@@ -239,9 +236,11 @@ describe("block streaming", () => {
|
||||
blockReplyTimeoutMs: 10,
|
||||
},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
telegram: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import {
|
||||
@@ -28,28 +28,18 @@ vi.mock("../agents/model-catalog.js", () => ({
|
||||
}));
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reply-"));
|
||||
const previousHome = process.env.HOME;
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
process.env.HOME = base;
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(base, ".clawdbot");
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(base, ".clawdbot", "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
if (previousPiAgentDir === undefined)
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
return withTempHomeBase(
|
||||
async (home) => {
|
||||
return await fn(home);
|
||||
},
|
||||
{
|
||||
env: {
|
||||
CLAWDBOT_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
|
||||
PI_CODING_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
|
||||
},
|
||||
prefix: "clawdbot-reply-",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("directive behavior", () => {
|
||||
@@ -78,11 +68,13 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: " help " },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: " help " },
|
||||
},
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
@@ -108,9 +100,11 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@@ -138,11 +132,13 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
messages: {
|
||||
queue: {
|
||||
mode: "collect",
|
||||
debounceMs: 1500,
|
||||
@@ -174,10 +170,12 @@ describe("directive behavior", () => {
|
||||
{ Body: "/think", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
thinkingDefault: "high",
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
thinkingDefault: "high",
|
||||
},
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
@@ -198,9 +196,11 @@ describe("directive behavior", () => {
|
||||
{ Body: "/think", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
@@ -232,9 +232,11 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@@ -270,9 +272,11 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@@ -303,9 +307,11 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
@@ -330,9 +336,11 @@ describe("directive behavior", () => {
|
||||
{ Body: "/verbose on", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
@@ -352,10 +360,12 @@ describe("directive behavior", () => {
|
||||
{ Body: "/think", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
thinkingDefault: "high",
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
thinkingDefault: "high",
|
||||
},
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
@@ -376,9 +386,11 @@ describe("directive behavior", () => {
|
||||
{ Body: "/think", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
@@ -399,10 +411,12 @@ describe("directive behavior", () => {
|
||||
{ Body: "/verbose", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
verboseDefault: "on",
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
verboseDefault: "on",
|
||||
},
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
@@ -423,9 +437,11 @@ describe("directive behavior", () => {
|
||||
{ Body: "/reasoning", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
@@ -452,10 +468,14 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
elevatedDefault: "on",
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
elevatedDefault: "on",
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
@@ -486,13 +506,17 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
sandbox: { mode: "off" },
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
sandbox: { mode: "off" },
|
||||
},
|
||||
whatsapp: { allowFrom: ["+1222"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@@ -520,9 +544,13 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
@@ -552,9 +580,13 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
@@ -585,9 +617,13 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
@@ -613,9 +649,11 @@ describe("directive behavior", () => {
|
||||
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
@@ -644,9 +682,11 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
@@ -677,9 +717,11 @@ describe("directive behavior", () => {
|
||||
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
@@ -690,9 +732,11 @@ describe("directive behavior", () => {
|
||||
{ Body: "/queue reset", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
@@ -749,9 +793,11 @@ describe("directive behavior", () => {
|
||||
ctx,
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
@@ -810,9 +856,11 @@ describe("directive behavior", () => {
|
||||
{ Body: "/verbose on", From: ctx.From, To: ctx.To },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
@@ -825,9 +873,11 @@ describe("directive behavior", () => {
|
||||
ctx,
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
@@ -853,12 +903,14 @@ describe("directive behavior", () => {
|
||||
{ Body: "/model", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -883,12 +935,14 @@ describe("directive behavior", () => {
|
||||
{ Body: "/model status", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -913,12 +967,14 @@ describe("directive behavior", () => {
|
||||
{ Body: "/model list", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -943,12 +999,14 @@ describe("directive behavior", () => {
|
||||
{ Body: "/model", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -972,11 +1030,13 @@ describe("directive behavior", () => {
|
||||
{ Body: "/model list", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -999,12 +1059,14 @@ describe("directive behavior", () => {
|
||||
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -1030,12 +1092,14 @@ describe("directive behavior", () => {
|
||||
{ Body: "/model Opus", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -1057,7 +1121,7 @@ describe("directive behavior", () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
const authDir = path.join(home, ".clawdbot", "agent");
|
||||
const authDir = path.join(home, ".clawdbot", "agents", "main", "agent");
|
||||
await fs.mkdir(authDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(
|
||||
path.join(authDir, "auth-profiles.json"),
|
||||
@@ -1081,12 +1145,14 @@ describe("directive behavior", () => {
|
||||
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -1112,12 +1178,14 @@ describe("directive behavior", () => {
|
||||
{ Body: "/model Opus", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -1151,12 +1219,14 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
@@ -1204,9 +1274,11 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
@@ -1242,9 +1314,13 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1004"] },
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
|
||||
vi.mock("../agents/model-fallback.js", () => ({
|
||||
@@ -43,23 +43,22 @@ vi.mock("../web/session.js", () => webMocks);
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-typing-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = base;
|
||||
try {
|
||||
runEmbeddedPiAgentMock.mockClear();
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
return withTempHomeBase(
|
||||
async (home) => {
|
||||
runEmbeddedPiAgentMock.mockClear();
|
||||
return await fn(home);
|
||||
},
|
||||
{ prefix: "clawdbot-typing-" },
|
||||
);
|
||||
}
|
||||
|
||||
function makeCfg(home: string) {
|
||||
return {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
@@ -28,27 +27,28 @@ function makeResult(text: string) {
|
||||
}
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-media-note-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = base;
|
||||
try {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
try {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup failures in tests
|
||||
}
|
||||
}
|
||||
return withTempHomeBase(
|
||||
async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
return await fn(home);
|
||||
},
|
||||
{
|
||||
env: {
|
||||
CLAWDBOT_BUNDLED_SKILLS_DIR: (home) =>
|
||||
path.join(home, "bundled-skills"),
|
||||
},
|
||||
prefix: "clawdbot-media-note-",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function makeCfg(home: string) {
|
||||
return {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import {
|
||||
isEmbeddedPiRunActive,
|
||||
isEmbeddedPiRunStreaming,
|
||||
@@ -32,31 +31,26 @@ function makeResult(text: string) {
|
||||
}
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-queue-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = base;
|
||||
try {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
try {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup failures in tests
|
||||
}
|
||||
}
|
||||
return withTempHomeBase(
|
||||
async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
return await fn(home);
|
||||
},
|
||||
{ prefix: "clawdbot-queue-" },
|
||||
);
|
||||
}
|
||||
|
||||
function makeCfg(home: string, queue?: Record<string, unknown>) {
|
||||
return {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
routing: queue ? { queue } : undefined,
|
||||
messages: queue ? { queue } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
@@ -25,13 +27,18 @@ const usageMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("../infra/provider-usage.js", () => usageMocks);
|
||||
|
||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
compactEmbeddedPiSession,
|
||||
runEmbeddedPiAgent,
|
||||
} from "../agents/pi-embedded.js";
|
||||
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
||||
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionKey,
|
||||
} from "../config/sessions.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
@@ -46,24 +53,23 @@ const webMocks = vi.hoisted(() => ({
|
||||
vi.mock("../web/session.js", () => webMocks);
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-triggers-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = base;
|
||||
try {
|
||||
vi.mocked(runEmbeddedPiAgent).mockClear();
|
||||
vi.mocked(abortEmbeddedPiRun).mockClear();
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
return withTempHomeBase(
|
||||
async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockClear();
|
||||
vi.mocked(abortEmbeddedPiRun).mockClear();
|
||||
return await fn(home);
|
||||
},
|
||||
{ prefix: "clawdbot-triggers-" },
|
||||
);
|
||||
}
|
||||
|
||||
function makeCfg(home: string) {
|
||||
return {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
@@ -94,7 +100,7 @@ describe("trigger handling", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("📊 Usage: Claude 80% left");
|
||||
expect(normalizeTestText(text ?? "")).toContain("Usage: Claude 80% left");
|
||||
expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["anthropic"] }),
|
||||
);
|
||||
@@ -293,7 +299,7 @@ describe("trigger handling", () => {
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("api-key");
|
||||
expect(text).toContain("…");
|
||||
expect(text).toMatch(/…|\.{3}/);
|
||||
expect(text).toContain("(anthropic:work)");
|
||||
expect(text).not.toContain("mixed");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
@@ -345,9 +351,11 @@ describe("trigger handling", () => {
|
||||
it("allows owner to set send policy", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
@@ -381,9 +389,13 @@ describe("trigger handling", () => {
|
||||
it("allows approved sender to toggle elevated mode", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
@@ -420,9 +432,13 @@ describe("trigger handling", () => {
|
||||
it("rejects elevated toggles when disabled", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
enabled: false,
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
@@ -467,9 +483,13 @@ describe("trigger handling", () => {
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
@@ -510,9 +530,13 @@ describe("trigger handling", () => {
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
@@ -545,9 +569,13 @@ describe("trigger handling", () => {
|
||||
it("allows elevated directive in groups when mentioned", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
@@ -589,9 +617,13 @@ describe("trigger handling", () => {
|
||||
it("allows elevated directive in direct chats without mentions", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
@@ -635,9 +667,13 @@ describe("trigger handling", () => {
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
@@ -668,9 +704,11 @@ describe("trigger handling", () => {
|
||||
it("falls back to discord dm allowFrom for elevated approval", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
dm: {
|
||||
@@ -708,9 +746,13 @@ describe("trigger handling", () => {
|
||||
it("treats explicit discord elevated allowlist as override", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { discord: [] },
|
||||
},
|
||||
@@ -799,9 +841,12 @@ describe("trigger handling", () => {
|
||||
});
|
||||
|
||||
const cfg = makeCfg(home);
|
||||
cfg.agent = {
|
||||
...cfg.agent,
|
||||
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
|
||||
cfg.agents = {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
|
||||
},
|
||||
};
|
||||
|
||||
await getReplyFromConfig(
|
||||
@@ -941,15 +986,17 @@ describe("trigger handling", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
routing: {
|
||||
messages: {
|
||||
groupChat: {},
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
@@ -985,9 +1032,11 @@ describe("trigger handling", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
@@ -1024,9 +1073,11 @@ describe("trigger handling", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
@@ -1056,9 +1107,11 @@ describe("trigger handling", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1999"],
|
||||
@@ -1083,9 +1136,11 @@ describe("trigger handling", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1999"],
|
||||
@@ -1124,9 +1179,11 @@ describe("trigger handling", () => {
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
@@ -1229,12 +1286,14 @@ describe("trigger handling", () => {
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
sandbox: {
|
||||
mode: "non-main" as const,
|
||||
workspaceRoot: join(home, "sandboxes"),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
sandbox: {
|
||||
mode: "non-main" as const,
|
||||
workspaceRoot: join(home, "sandboxes"),
|
||||
},
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
@@ -1272,10 +1331,11 @@ describe("trigger handling", () => {
|
||||
ctx,
|
||||
cfg.session?.mainKey,
|
||||
);
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const sandbox = await ensureSandboxWorkspaceForSession({
|
||||
config: cfg,
|
||||
sessionKey,
|
||||
workspaceDir: cfg.agent.workspace,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
});
|
||||
expect(sandbox).not.toBeNull();
|
||||
if (!sandbox) {
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function getReplyFromConfig(
|
||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
|
||||
const agentCfg = cfg.agent;
|
||||
const agentCfg = cfg.agents?.defaults;
|
||||
const sessionCfg = cfg.session;
|
||||
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
|
||||
cfg,
|
||||
@@ -239,7 +239,7 @@ export async function getReplyFromConfig(
|
||||
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDirRaw,
|
||||
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
@@ -257,7 +257,7 @@ export async function getReplyFromConfig(
|
||||
opts?.onTypingController?.(typing);
|
||||
|
||||
let transcribedText: string | undefined;
|
||||
if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) {
|
||||
if (cfg.audio?.transcription && isAudio(ctx.MediaType)) {
|
||||
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
|
||||
if (transcribed?.text) {
|
||||
transcribedText = transcribed.text;
|
||||
@@ -329,7 +329,7 @@ export async function getReplyFromConfig(
|
||||
cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()),
|
||||
),
|
||||
);
|
||||
const configuredAliases = Object.values(cfg.agent?.models ?? {})
|
||||
const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {})
|
||||
.map((entry) => entry.alias?.trim())
|
||||
.filter((alias): alias is string => Boolean(alias))
|
||||
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
|
||||
@@ -391,7 +391,7 @@ export async function getReplyFromConfig(
|
||||
sessionCtx.Provider?.trim().toLowerCase() ??
|
||||
ctx.Provider?.trim().toLowerCase() ??
|
||||
"";
|
||||
const elevatedConfig = agentCfg?.elevated;
|
||||
const elevatedConfig = cfg.tools?.elevated;
|
||||
const discordElevatedFallback =
|
||||
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
|
||||
const elevatedEnabled = elevatedConfig?.enabled !== false;
|
||||
@@ -582,6 +582,7 @@ export async function getReplyFromConfig(
|
||||
directives,
|
||||
effectiveModelDirective,
|
||||
cfg,
|
||||
agentDir,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads.js";
|
||||
import {
|
||||
createReplyToModeFilter,
|
||||
createReplyToModeFilterForChannel,
|
||||
resolveReplyToMode,
|
||||
} from "./reply-threading.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
@@ -260,7 +260,10 @@ export async function runReplyAgent(params: {
|
||||
followupRun.run.config,
|
||||
replyToChannel,
|
||||
);
|
||||
const applyReplyToMode = createReplyToModeFilter(replyToMode);
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(
|
||||
replyToMode,
|
||||
replyToChannel,
|
||||
);
|
||||
const cfg = followupRun.run.config;
|
||||
|
||||
if (shouldSteer && isStreaming) {
|
||||
@@ -716,7 +719,8 @@ export async function runReplyAgent(params: {
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||
payloads: sanitizedPayloads,
|
||||
applyReplyToMode,
|
||||
replyToMode,
|
||||
replyToChannel,
|
||||
currentMessageId: sessionCtx.MessageSid,
|
||||
})
|
||||
.map((payload) => {
|
||||
|
||||
@@ -34,7 +34,7 @@ export function resolveBlockStreamingChunking(
|
||||
} {
|
||||
const providerKey = normalizeChunkProvider(provider);
|
||||
const textLimit = resolveTextChunkLimit(cfg, providerKey);
|
||||
const chunkCfg = cfg?.agent?.blockStreamingChunk;
|
||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||
const maxRequested = Math.max(
|
||||
1,
|
||||
Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
@@ -16,6 +20,13 @@ import {
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
getConfigOverrides,
|
||||
resetConfigOverrides,
|
||||
setConfigOverride,
|
||||
unsetConfigOverride,
|
||||
} from "../../config/runtime-overrides.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionFilePath,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
@@ -61,6 +72,7 @@ import type {
|
||||
} from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
|
||||
@@ -135,6 +147,10 @@ export async function buildStatusReply(params: {
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const statusAgentId = sessionKey
|
||||
? resolveAgentIdFromSessionKey(sessionKey)
|
||||
: resolveDefaultAgentId(cfg);
|
||||
const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
|
||||
let usageLine: string | null = null;
|
||||
try {
|
||||
const usageProvider = resolveUsageProviderId(provider);
|
||||
@@ -142,8 +158,18 @@ export async function buildStatusReply(params: {
|
||||
const usageSummary = await loadProviderUsageSummary({
|
||||
timeoutMs: 3500,
|
||||
providers: [usageProvider],
|
||||
agentDir: statusAgentDir,
|
||||
});
|
||||
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
|
||||
if (
|
||||
!usageLine &&
|
||||
(resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on")
|
||||
) {
|
||||
const entry = usageSummary.providers[0];
|
||||
if (entry?.error) {
|
||||
usageLine = `📊 Usage: ${entry.displayName} (${entry.error})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
usageLine = null;
|
||||
@@ -164,18 +190,19 @@ export async function buildStatusReply(params: {
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
|
||||
defaultGroupActivation())
|
||||
: undefined;
|
||||
const agentDefaults = cfg.agents?.defaults ?? {};
|
||||
const statusText = buildStatusMessage({
|
||||
config: cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
...agentDefaults,
|
||||
model: {
|
||||
...cfg.agent?.model,
|
||||
...agentDefaults.model,
|
||||
primary: `${provider}/${model}`,
|
||||
},
|
||||
contextTokens,
|
||||
thinkingDefault: cfg.agent?.thinkingDefault,
|
||||
verboseDefault: cfg.agent?.verboseDefault,
|
||||
elevatedDefault: cfg.agent?.elevatedDefault,
|
||||
thinkingDefault: agentDefaults.thinkingDefault,
|
||||
verboseDefault: agentDefaults.verboseDefault,
|
||||
elevatedDefault: agentDefaults.elevatedDefault,
|
||||
},
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
@@ -185,7 +212,12 @@ export async function buildStatusReply(params: {
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
resolvedReasoning: resolvedReasoningLevel,
|
||||
resolvedElevated: resolvedElevatedLevel,
|
||||
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry),
|
||||
modelAuth: resolveModelAuthLabel(
|
||||
provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
statusAgentDir,
|
||||
),
|
||||
usageLine: usageLine ?? undefined,
|
||||
queue: {
|
||||
mode: queueSettings.mode,
|
||||
@@ -213,12 +245,15 @@ function resolveModelAuthLabel(
|
||||
provider?: string,
|
||||
cfg?: ClawdbotConfig,
|
||||
sessionEntry?: SessionEntry,
|
||||
agentDir?: string,
|
||||
): string | undefined {
|
||||
const resolved = provider?.trim();
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const providerKey = normalizeProviderId(resolved);
|
||||
const store = ensureAuthProfileStore();
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const profileOverride = sessionEntry?.authProfileOverride?.trim();
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
@@ -593,6 +628,88 @@ export async function handleCommands(params: {
|
||||
return { shouldContinue: false, reply };
|
||||
}
|
||||
|
||||
const debugCommand = allowTextCommands
|
||||
? parseDebugCommand(command.commandBodyNormalized)
|
||||
: null;
|
||||
if (debugCommand) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /debug from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (debugCommand.action === "error") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${debugCommand.message}` },
|
||||
};
|
||||
}
|
||||
if (debugCommand.action === "show") {
|
||||
const overrides = getConfigOverrides();
|
||||
const hasOverrides = Object.keys(overrides).length > 0;
|
||||
if (!hasOverrides) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Debug overrides: (none)" },
|
||||
};
|
||||
}
|
||||
const json = JSON.stringify(overrides, null, 2);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (debugCommand.action === "reset") {
|
||||
resetConfigOverrides();
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Debug overrides cleared; using config on disk." },
|
||||
};
|
||||
}
|
||||
if (debugCommand.action === "unset") {
|
||||
const result = unsetConfigOverride(debugCommand.path);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${result.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
if (!result.removed) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ No debug override found for ${debugCommand.path}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Debug override removed for ${debugCommand.path}.` },
|
||||
};
|
||||
}
|
||||
if (debugCommand.action === "set") {
|
||||
const result = setConfigOverride(debugCommand.path, debugCommand.value);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${result.error ?? "Invalid override."}` },
|
||||
};
|
||||
}
|
||||
const valueLabel =
|
||||
typeof debugCommand.value === "string"
|
||||
? `"${debugCommand.value}"`
|
||||
: JSON.stringify(debugCommand.value);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Debug override set: ${debugCommand.path}=${valueLabel ?? "null"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const stopRequested = command.commandBodyNormalized === "/stop";
|
||||
if (allowTextCommands && stopRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
|
||||
21
src/auto-reply/reply/debug-commands.test.ts
Normal file
21
src/auto-reply/reply/debug-commands.test.ts
Normal file
@@ -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" });
|
||||
});
|
||||
});
|
||||
99
src/auto-reply/reply/debug-commands.ts
Normal file
99
src/auto-reply/reply/debug-commands.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type DebugCommand =
|
||||
| { action: "show" }
|
||||
| { action: "reset" }
|
||||
| { action: "set"; path: string; value: unknown }
|
||||
| { action: "unset"; path: string }
|
||||
| { action: "error"; message: string };
|
||||
|
||||
function parseDebugValue(raw: string): { value?: unknown; error?: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { error: "Missing value." };
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch (err) {
|
||||
return { error: `Invalid JSON: ${String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === "true") return { value: true };
|
||||
if (trimmed === "false") return { value: false };
|
||||
if (trimmed === "null") return { value: null };
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const num = Number(trimmed);
|
||||
if (Number.isFinite(num)) return { value: num };
|
||||
}
|
||||
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
const unquoted = trimmed.slice(1, -1);
|
||||
return { value: unquoted };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: trimmed };
|
||||
}
|
||||
|
||||
export function parseDebugCommand(raw: string): DebugCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/debug")) return null;
|
||||
const rest = trimmed.slice("/debug".length).trim();
|
||||
if (!rest) return { action: "show" };
|
||||
|
||||
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
||||
if (!match) return { action: "error", message: "Invalid /debug syntax." };
|
||||
const action = match[1].toLowerCase();
|
||||
const args = (match[2] ?? "").trim();
|
||||
|
||||
switch (action) {
|
||||
case "show":
|
||||
return { action: "show" };
|
||||
case "reset":
|
||||
return { action: "reset" };
|
||||
case "unset": {
|
||||
if (!args)
|
||||
return { action: "error", message: "Usage: /debug unset path" };
|
||||
return { action: "unset", path: args };
|
||||
}
|
||||
case "set": {
|
||||
if (!args) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /debug set path=value",
|
||||
};
|
||||
}
|
||||
const eqIndex = args.indexOf("=");
|
||||
if (eqIndex <= 0) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /debug set path=value",
|
||||
};
|
||||
}
|
||||
const path = args.slice(0, eqIndex).trim();
|
||||
const rawValue = args.slice(eqIndex + 1);
|
||||
if (!path) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /debug set path=value",
|
||||
};
|
||||
}
|
||||
const parsed = parseDebugValue(rawValue);
|
||||
if (parsed.error) {
|
||||
return { action: "error", message: parsed.error };
|
||||
}
|
||||
return { action: "set", path, value: parsed.value };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /debug show|set|unset|reset",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import { resolveAgentConfig } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import {
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthStorePathForDisplay,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
@@ -20,9 +24,11 @@ import {
|
||||
buildModelAliasIndex,
|
||||
type ModelAliasIndex,
|
||||
modelKey,
|
||||
normalizeProviderId,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { resolveSandboxConfigForAgent } from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
@@ -72,18 +78,111 @@ const maskApiKey = (value: string): string => {
|
||||
return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`;
|
||||
};
|
||||
|
||||
type ModelAuthDetailMode = "compact" | "verbose";
|
||||
|
||||
const resolveAuthLabel = async (
|
||||
provider: string,
|
||||
cfg: ClawdbotConfig,
|
||||
modelsPath: string,
|
||||
agentDir?: string,
|
||||
mode: ModelAuthDetailMode = "compact",
|
||||
): Promise<{ label: string; source: string }> => {
|
||||
const formatPath = (value: string) => shortenHomePath(value);
|
||||
const store = ensureAuthProfileStore();
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||
const providerKey = normalizeProviderId(provider);
|
||||
const lastGood = (() => {
|
||||
const map = store.lastGood;
|
||||
if (!map) return undefined;
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
if (normalizeProviderId(key) === providerKey) return value;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const nextProfileId = order[0];
|
||||
const now = Date.now();
|
||||
|
||||
const formatUntil = (timestampMs: number) => {
|
||||
const remainingMs = Math.max(0, timestampMs - now);
|
||||
const minutes = Math.round(remainingMs / 60_000);
|
||||
if (minutes < 1) return "soon";
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d`;
|
||||
};
|
||||
|
||||
if (order.length > 0) {
|
||||
if (mode === "compact") {
|
||||
const profileId = nextProfileId;
|
||||
if (!profileId) return { label: "missing", source: "missing" };
|
||||
const profile = store.profiles[profileId];
|
||||
const configProfile = cfg.auth?.profiles?.[profileId];
|
||||
const missing =
|
||||
!profile ||
|
||||
(configProfile?.provider &&
|
||||
configProfile.provider !== profile.provider) ||
|
||||
(configProfile?.mode &&
|
||||
configProfile.mode !== profile.type &&
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"));
|
||||
|
||||
const more = order.length > 1 ? ` (+${order.length - 1})` : "";
|
||||
if (missing) return { label: `${profileId} missing${more}`, source: "" };
|
||||
|
||||
if (profile.type === "api_key") {
|
||||
return {
|
||||
label: `${profileId} api-key ${maskApiKey(profile.key)}${more}`,
|
||||
source: "",
|
||||
};
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
const exp =
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
? profile.expires <= now
|
||||
? " expired"
|
||||
: ` exp ${formatUntil(profile.expires)}`
|
||||
: "";
|
||||
return {
|
||||
label: `${profileId} token ${maskApiKey(profile.token)}${exp}${more}`,
|
||||
source: "",
|
||||
};
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
||||
const label = display === profileId ? profileId : display;
|
||||
const exp =
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
? profile.expires <= now
|
||||
? " expired"
|
||||
: ` exp ${formatUntil(profile.expires)}`
|
||||
: "";
|
||||
return { label: `${label} oauth${exp}${more}`, source: "" };
|
||||
}
|
||||
|
||||
const labels = order.map((profileId) => {
|
||||
const profile = store.profiles[profileId];
|
||||
const configProfile = cfg.auth?.profiles?.[profileId];
|
||||
const flags: string[] = [];
|
||||
if (profileId === nextProfileId) flags.push("next");
|
||||
if (lastGood && profileId === lastGood) flags.push("lastGood");
|
||||
if (isProfileInCooldown(store, profileId)) {
|
||||
const until = store.usageStats?.[profileId]?.cooldownUntil;
|
||||
if (
|
||||
typeof until === "number" &&
|
||||
Number.isFinite(until) &&
|
||||
until > now
|
||||
) {
|
||||
flags.push(`cooldown ${formatUntil(until)}`);
|
||||
} else {
|
||||
flags.push("cooldown");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!profile ||
|
||||
(configProfile?.provider &&
|
||||
@@ -92,13 +191,27 @@ const resolveAuthLabel = async (
|
||||
configProfile.mode !== profile.type &&
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"))
|
||||
) {
|
||||
return `${profileId}=missing`;
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
return `${profileId}=missing${suffix}`;
|
||||
}
|
||||
if (profile.type === "api_key") {
|
||||
return `${profileId}=${maskApiKey(profile.key)}`;
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
return `${profileId}=${maskApiKey(profile.key)}${suffix}`;
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
return `${profileId}=token:${maskApiKey(profile.token)}`;
|
||||
if (
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
) {
|
||||
flags.push(
|
||||
profile.expires <= now
|
||||
? "expired"
|
||||
: `exp ${formatUntil(profile.expires)}`,
|
||||
);
|
||||
}
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`;
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({
|
||||
cfg,
|
||||
@@ -111,13 +224,24 @@ const resolveAuthLabel = async (
|
||||
: display.startsWith(profileId)
|
||||
? display.slice(profileId.length).trim()
|
||||
: `(${display})`;
|
||||
return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
|
||||
if (
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
) {
|
||||
flags.push(
|
||||
profile.expires <= now
|
||||
? "expired"
|
||||
: `exp ${formatUntil(profile.expires)}`,
|
||||
);
|
||||
}
|
||||
const suffixLabel = suffix ? ` ${suffix}` : "";
|
||||
const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
return `${profileId}=OAuth${suffixLabel}${suffixFlags}`;
|
||||
});
|
||||
return {
|
||||
label: labels.join(", "),
|
||||
source: `auth-profiles.json: ${formatPath(
|
||||
resolveAuthStorePathForDisplay(),
|
||||
)}`,
|
||||
source: `auth-profiles.json: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,13 +251,14 @@ const resolveAuthLabel = async (
|
||||
envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") ||
|
||||
envKey.source.toLowerCase().includes("oauth");
|
||||
const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey);
|
||||
return { label, source: envKey.source };
|
||||
return { label, source: mode === "verbose" ? envKey.source : "" };
|
||||
}
|
||||
const customKey = getCustomProviderApiKey(cfg, provider);
|
||||
if (customKey) {
|
||||
return {
|
||||
label: maskApiKey(customKey),
|
||||
source: `models.json: ${formatPath(modelsPath)}`,
|
||||
source:
|
||||
mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "",
|
||||
};
|
||||
}
|
||||
return { label: "missing", source: "missing" };
|
||||
@@ -150,10 +275,13 @@ const resolveProfileOverride = (params: {
|
||||
rawProfile?: string;
|
||||
provider: string;
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
}): { profileId?: string; error?: string } => {
|
||||
const raw = params.rawProfile?.trim();
|
||||
if (!raw) return {};
|
||||
const store = ensureAuthProfileStore();
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const profile = store.profiles[raw];
|
||||
if (!profile) {
|
||||
return { error: `Auth profile "${raw}" not found.` };
|
||||
@@ -362,17 +490,21 @@ export async function handleDirectiveOnly(params: {
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
} = params;
|
||||
const activeAgentId = params.sessionKey
|
||||
? resolveAgentIdFromSessionKey(params.sessionKey)
|
||||
: resolveDefaultAgentId(params.cfg);
|
||||
const agentDir = resolveAgentDir(params.cfg, activeAgentId);
|
||||
const runtimeIsSandboxed = (() => {
|
||||
const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off";
|
||||
if (sandboxMode === "off") return false;
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!sessionKey) return false;
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId);
|
||||
if (sandboxCfg.mode === "off") return false;
|
||||
const mainKey = resolveAgentMainSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
});
|
||||
if (sandboxMode === "all") return true;
|
||||
if (sandboxCfg.mode === "all") return true;
|
||||
return sessionKey !== mainKey;
|
||||
})();
|
||||
const shouldHintDirectRuntime =
|
||||
@@ -383,6 +515,10 @@ export async function handleDirectiveOnly(params: {
|
||||
const isModelListAlias =
|
||||
modelDirective === "status" || modelDirective === "list";
|
||||
if (!directives.rawModelDirective || isModelListAlias) {
|
||||
const modelsPath = `${agentDir}/models.json`;
|
||||
const formatPath = (value: string) => shortenHomePath(value);
|
||||
const authMode: ModelAuthDetailMode =
|
||||
modelDirective === "status" ? "verbose" : "compact";
|
||||
if (allowedModelCatalog.length === 0) {
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg: params.cfg,
|
||||
@@ -394,7 +530,9 @@ export async function handleDirectiveOnly(params: {
|
||||
provider: string;
|
||||
id: string;
|
||||
}> = [];
|
||||
for (const raw of Object.keys(params.cfg.agent?.models ?? {})) {
|
||||
for (const raw of Object.keys(
|
||||
params.cfg.agents?.defaults?.models ?? {},
|
||||
)) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw),
|
||||
defaultProvider,
|
||||
@@ -420,9 +558,6 @@ export async function handleDirectiveOnly(params: {
|
||||
if (fallbackCatalog.length === 0) {
|
||||
return { text: "No models available." };
|
||||
}
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const modelsPath = `${agentDir}/models.json`;
|
||||
const formatPath = (value: string) => shortenHomePath(value);
|
||||
const authByProvider = new Map<string, string>();
|
||||
for (const entry of fallbackCatalog) {
|
||||
if (authByProvider.has(entry.provider)) continue;
|
||||
@@ -430,6 +565,8 @@ export async function handleDirectiveOnly(params: {
|
||||
entry.provider,
|
||||
params.cfg,
|
||||
modelsPath,
|
||||
agentDir,
|
||||
authMode,
|
||||
);
|
||||
authByProvider.set(entry.provider, formatAuthLabel(auth));
|
||||
}
|
||||
@@ -438,7 +575,8 @@ export async function handleDirectiveOnly(params: {
|
||||
const lines = [
|
||||
`Current: ${current}`,
|
||||
`Default: ${defaultLabel}`,
|
||||
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
|
||||
`Agent: ${activeAgentId}`,
|
||||
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
|
||||
`⚠️ Model catalog unavailable; showing configured models only.`,
|
||||
];
|
||||
const byProvider = new Map<string, typeof fallbackCatalog>();
|
||||
@@ -466,9 +604,6 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
return { text: lines.join("\n") };
|
||||
}
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const modelsPath = `${agentDir}/models.json`;
|
||||
const formatPath = (value: string) => shortenHomePath(value);
|
||||
const authByProvider = new Map<string, string>();
|
||||
for (const entry of allowedModelCatalog) {
|
||||
if (authByProvider.has(entry.provider)) continue;
|
||||
@@ -476,6 +611,8 @@ export async function handleDirectiveOnly(params: {
|
||||
entry.provider,
|
||||
params.cfg,
|
||||
modelsPath,
|
||||
agentDir,
|
||||
authMode,
|
||||
);
|
||||
authByProvider.set(entry.provider, formatAuthLabel(auth));
|
||||
}
|
||||
@@ -484,7 +621,8 @@ export async function handleDirectiveOnly(params: {
|
||||
const lines = [
|
||||
`Current: ${current}`,
|
||||
`Default: ${defaultLabel}`,
|
||||
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
|
||||
`Agent: ${activeAgentId}`,
|
||||
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
|
||||
];
|
||||
if (resetModelOverride) {
|
||||
lines.push(`(previous selection reset to default)`);
|
||||
@@ -686,6 +824,7 @@ export async function handleDirectiveOnly(params: {
|
||||
rawProfile: directives.rawModelProfile,
|
||||
provider: modelSelection.provider,
|
||||
cfg: params.cfg,
|
||||
agentDir,
|
||||
});
|
||||
if (profileResolved.error) {
|
||||
return { text: profileResolved.error };
|
||||
@@ -837,6 +976,7 @@ export async function persistInlineDirectives(params: {
|
||||
directives: InlineDirectives;
|
||||
effectiveModelDirective?: string;
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
@@ -851,7 +991,7 @@ export async function persistInlineDirectives(params: {
|
||||
model: string;
|
||||
initialModelLabel: string;
|
||||
formatModelSwitchEvent: (label: string, alias?: string) => string;
|
||||
agentCfg: ClawdbotConfig["agent"] | undefined;
|
||||
agentCfg: NonNullable<ClawdbotConfig["agents"]>["defaults"] | undefined;
|
||||
}): Promise<{ provider: string; model: string; contextTokens: number }> {
|
||||
const {
|
||||
directives,
|
||||
@@ -871,6 +1011,10 @@ export async function persistInlineDirectives(params: {
|
||||
agentCfg,
|
||||
} = params;
|
||||
let { provider, model } = params;
|
||||
const activeAgentId = sessionKey
|
||||
? resolveAgentIdFromSessionKey(sessionKey)
|
||||
: resolveDefaultAgentId(cfg);
|
||||
const agentDir = resolveAgentDir(cfg, activeAgentId);
|
||||
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
let updated = false;
|
||||
@@ -930,6 +1074,7 @@ export async function persistInlineDirectives(params: {
|
||||
rawProfile: directives.rawModelProfile,
|
||||
provider: resolved.ref.provider,
|
||||
cfg,
|
||||
agentDir,
|
||||
});
|
||||
if (profileResolved.error) {
|
||||
throw new Error(profileResolved.error);
|
||||
@@ -1007,13 +1152,16 @@ export function resolveDefaultModel(params: {
|
||||
agentModelOverride && agentModelOverride.length > 0
|
||||
? {
|
||||
...params.cfg,
|
||||
agent: {
|
||||
...params.cfg.agent,
|
||||
model: {
|
||||
...(typeof params.cfg.agent?.model === "object"
|
||||
? params.cfg.agent.model
|
||||
: undefined),
|
||||
primary: agentModelOverride,
|
||||
agents: {
|
||||
...params.cfg.agents,
|
||||
defaults: {
|
||||
...params.cfg.agents?.defaults,
|
||||
model: {
|
||||
...(typeof params.cfg.agents?.defaults?.model === "object"
|
||||
? params.cfg.agents.defaults.model
|
||||
: undefined),
|
||||
primary: agentModelOverride,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
payload,
|
||||
channel: originatingChannel,
|
||||
to: originatingTo,
|
||||
sessionKey: ctx.SessionKey,
|
||||
accountId: ctx.AccountId,
|
||||
threadId: ctx.MessageThreadId,
|
||||
cfg,
|
||||
@@ -106,6 +107,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
payload: reply,
|
||||
channel: originatingChannel,
|
||||
to: originatingTo,
|
||||
sessionKey: ctx.SessionKey,
|
||||
accountId: ctx.AccountId,
|
||||
threadId: ctx.MessageThreadId,
|
||||
cfg,
|
||||
|
||||
@@ -19,10 +19,7 @@ import {
|
||||
filterMessagingToolDuplicates,
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads.js";
|
||||
import {
|
||||
createReplyToModeFilter,
|
||||
resolveReplyToMode,
|
||||
} from "./reply-threading.js";
|
||||
import { resolveReplyToMode } from "./reply-threading.js";
|
||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
@@ -97,6 +94,7 @@ export function createFollowupRunner(params: {
|
||||
payload,
|
||||
channel: originatingChannel,
|
||||
to: originatingTo,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
accountId: queued.originatingAccountId,
|
||||
threadId: queued.originatingThreadId,
|
||||
cfg: queued.run.config,
|
||||
@@ -194,13 +192,12 @@ export function createFollowupRunner(params: {
|
||||
(queued.run.messageProvider?.toLowerCase() as
|
||||
| OriginatingChannelType
|
||||
| undefined);
|
||||
const applyReplyToMode = createReplyToModeFilter(
|
||||
resolveReplyToMode(queued.run.config, replyToChannel),
|
||||
);
|
||||
const replyToMode = resolveReplyToMode(queued.run.config, replyToChannel);
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||
payloads: sanitizedPayloads,
|
||||
applyReplyToMode,
|
||||
replyToMode,
|
||||
replyToChannel,
|
||||
});
|
||||
|
||||
const dedupedPayloads = filterMessagingToolDuplicates({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
describe("mention helpers", () => {
|
||||
it("builds regexes and skips invalid patterns", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
routing: {
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
|
||||
},
|
||||
});
|
||||
@@ -23,7 +23,7 @@ describe("mention helpers", () => {
|
||||
|
||||
it("matches patterns case-insensitively", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
routing: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
|
||||
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
|
||||
});
|
||||
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
|
||||
});
|
||||
@@ -31,11 +31,16 @@ describe("mention helpers", () => {
|
||||
it("uses per-agent mention patterns when configured", () => {
|
||||
const regexes = buildMentionRegexes(
|
||||
{
|
||||
routing: {
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
|
||||
agents: {
|
||||
work: { mentionPatterns: ["\\bworkbot\\b"] },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"work",
|
||||
|
||||
@@ -1,23 +1,62 @@
|
||||
import { resolveAgentConfig } from "../../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function deriveMentionPatterns(identity?: { name?: string; emoji?: string }) {
|
||||
const patterns: string[] = [];
|
||||
const name = identity?.name?.trim();
|
||||
if (name) {
|
||||
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
|
||||
const re = parts.length ? parts.join(String.raw`\s+`) : escapeRegExp(name);
|
||||
patterns.push(String.raw`\b@?${re}\b`);
|
||||
}
|
||||
const emoji = identity?.emoji?.trim();
|
||||
if (emoji) {
|
||||
patterns.push(escapeRegExp(emoji));
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
const BACKSPACE_CHAR = "\u0008";
|
||||
|
||||
function normalizeMentionPattern(pattern: string): string {
|
||||
if (!pattern.includes(BACKSPACE_CHAR)) return pattern;
|
||||
return pattern.split(BACKSPACE_CHAR).join("\\b");
|
||||
}
|
||||
|
||||
function normalizeMentionPatterns(patterns: string[]): string[] {
|
||||
return patterns.map(normalizeMentionPattern);
|
||||
}
|
||||
|
||||
function resolveMentionPatterns(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
agentId?: string,
|
||||
): string[] {
|
||||
if (!cfg) return [];
|
||||
const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined;
|
||||
if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) {
|
||||
return agentConfig.mentionPatterns ?? [];
|
||||
const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
|
||||
const agentGroupChat = agentConfig?.groupChat;
|
||||
if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) {
|
||||
return agentGroupChat.mentionPatterns ?? [];
|
||||
}
|
||||
return cfg.routing?.groupChat?.mentionPatterns ?? [];
|
||||
const globalGroupChat = cfg.messages?.groupChat;
|
||||
if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) {
|
||||
return globalGroupChat.mentionPatterns ?? [];
|
||||
}
|
||||
const derived = deriveMentionPatterns(agentConfig?.identity);
|
||||
return derived.length > 0 ? derived : [];
|
||||
}
|
||||
|
||||
export function buildMentionRegexes(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
agentId?: string,
|
||||
): RegExp[] {
|
||||
const patterns = resolveMentionPatterns(cfg, agentId);
|
||||
const patterns = normalizeMentionPatterns(
|
||||
resolveMentionPatterns(cfg, agentId),
|
||||
);
|
||||
return patterns
|
||||
.map((pattern) => {
|
||||
try {
|
||||
@@ -66,7 +105,9 @@ export function stripMentions(
|
||||
agentId?: string,
|
||||
): string {
|
||||
let result = text;
|
||||
const patterns = resolveMentionPatterns(cfg, agentId);
|
||||
const patterns = normalizeMentionPatterns(
|
||||
resolveMentionPatterns(cfg, agentId),
|
||||
);
|
||||
for (const p of patterns) {
|
||||
try {
|
||||
const re = new RegExp(p, "gi");
|
||||
|
||||
@@ -33,7 +33,9 @@ type ModelSelectionState = {
|
||||
|
||||
export async function createModelSelectionState(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentCfg: ClawdbotConfig["agent"] | undefined;
|
||||
agentCfg:
|
||||
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
|
||||
| undefined;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
@@ -201,7 +203,9 @@ export function resolveModelDirectiveSelection(params: {
|
||||
}
|
||||
|
||||
export function resolveContextTokens(params: {
|
||||
agentCfg: ClawdbotConfig["agent"] | undefined;
|
||||
agentCfg:
|
||||
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
|
||||
| undefined;
|
||||
model: string;
|
||||
}): number {
|
||||
return (
|
||||
|
||||
49
src/auto-reply/reply/normalize-reply.ts
Normal file
49
src/auto-reply/reply/normalize-reply.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
responsePrefix?: string;
|
||||
onHeartbeatStrip?: () => void;
|
||||
stripHeartbeat?: boolean;
|
||||
silentToken?: string;
|
||||
};
|
||||
|
||||
export function normalizeReplyPayload(
|
||||
payload: ReplyPayload,
|
||||
opts: NormalizeReplyOptions = {},
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia) return null;
|
||||
|
||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||
if (trimmed === silentToken && !hasMedia) return null;
|
||||
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && !trimmed) {
|
||||
// Keep empty text when media exists so media-only replies still send.
|
||||
text = "";
|
||||
}
|
||||
|
||||
const shouldStripHeartbeat = opts.stripHeartbeat ?? true;
|
||||
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.shouldSkip && !hasMedia) return null;
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
if (
|
||||
opts.responsePrefix &&
|
||||
text &&
|
||||
text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!text.startsWith(opts.responsePrefix)
|
||||
) {
|
||||
text = `${opts.responsePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...payload, text };
|
||||
}
|
||||
@@ -553,7 +553,7 @@ export function resolveQueueSettings(params: {
|
||||
inlineOptions?: Partial<QueueSettings>;
|
||||
}): QueueSettings {
|
||||
const providerKey = params.provider?.trim().toLowerCase();
|
||||
const queueCfg = params.cfg.routing?.queue;
|
||||
const queueCfg = params.cfg.messages?.queue;
|
||||
const providerModeRaw =
|
||||
providerKey && queueCfg?.byProvider
|
||||
? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export type ReplyDispatchKind = "tool" | "block" | "final";
|
||||
@@ -45,41 +44,14 @@ export type ReplyDispatcher = {
|
||||
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
|
||||
};
|
||||
|
||||
function normalizeReplyPayload(
|
||||
function normalizeReplyPayloadInternal(
|
||||
payload: ReplyPayload,
|
||||
opts: Pick<ReplyDispatcherOptions, "responsePrefix" | "onHeartbeatStrip">,
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia) return null;
|
||||
|
||||
// Avoid sending the explicit silent token when no media is attached.
|
||||
if (trimmed === SILENT_REPLY_TOKEN && !hasMedia) return null;
|
||||
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && !trimmed) {
|
||||
// Keep empty text when media exists so media-only replies still send.
|
||||
text = "";
|
||||
}
|
||||
if (text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.shouldSkip && !hasMedia) return null;
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
if (
|
||||
opts.responsePrefix &&
|
||||
text &&
|
||||
text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!text.startsWith(opts.responsePrefix)
|
||||
) {
|
||||
text = `${opts.responsePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...payload, text };
|
||||
return normalizeReplyPayload(payload, {
|
||||
responsePrefix: opts.responsePrefix,
|
||||
onHeartbeatStrip: opts.onHeartbeatStrip,
|
||||
});
|
||||
}
|
||||
|
||||
export function createReplyDispatcher(
|
||||
@@ -96,7 +68,7 @@ export function createReplyDispatcher(
|
||||
};
|
||||
|
||||
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
||||
const normalized = normalizeReplyPayload(payload, options);
|
||||
const normalized = normalizeReplyPayloadInternal(payload, options);
|
||||
if (!normalized) return false;
|
||||
queuedCounts[kind] += 1;
|
||||
pending += 1;
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
||||
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
|
||||
export type ReplyToModeFilter = (payload: ReplyPayload) => ReplyPayload;
|
||||
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
|
||||
|
||||
export function applyReplyTagsToPayload(
|
||||
payload: ReplyPayload,
|
||||
currentMessageId?: string,
|
||||
): ReplyPayload {
|
||||
if (typeof payload.text !== "string") return payload;
|
||||
const { cleaned, replyToId } = extractReplyToTag(
|
||||
const { cleaned, replyToId, hasTag } = extractReplyToTag(
|
||||
payload.text,
|
||||
currentMessageId,
|
||||
);
|
||||
@@ -18,6 +19,7 @@ export function applyReplyTagsToPayload(
|
||||
...payload,
|
||||
text: cleaned ? cleaned : undefined,
|
||||
replyToId: replyToId ?? payload.replyToId,
|
||||
replyToTag: hasTag || payload.replyToTag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,10 +33,15 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||
|
||||
export function applyReplyThreading(params: {
|
||||
payloads: ReplyPayload[];
|
||||
applyReplyToMode: ReplyToModeFilter;
|
||||
replyToMode: ReplyToMode;
|
||||
replyToChannel?: OriginatingChannelType;
|
||||
currentMessageId?: string;
|
||||
}): ReplyPayload[] {
|
||||
const { payloads, applyReplyToMode, currentMessageId } = params;
|
||||
const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(
|
||||
replyToMode,
|
||||
replyToChannel,
|
||||
);
|
||||
return payloads
|
||||
.map((payload) => applyReplyTagsToPayload(payload, currentMessageId))
|
||||
.filter(isRenderablePayload)
|
||||
|
||||
@@ -40,6 +40,13 @@ describe("createReplyToModeFilter", () => {
|
||||
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");
|
||||
|
||||
@@ -19,11 +19,15 @@ export function resolveReplyToMode(
|
||||
}
|
||||
}
|
||||
|
||||
export function createReplyToModeFilter(mode: ReplyToMode) {
|
||||
export function createReplyToModeFilter(
|
||||
mode: ReplyToMode,
|
||||
opts: { allowTagsWhenOff?: boolean } = {},
|
||||
) {
|
||||
let hasThreaded = false;
|
||||
return (payload: ReplyPayload): ReplyPayload => {
|
||||
if (!payload.replyToId) return payload;
|
||||
if (mode === "off") {
|
||||
if (opts.allowTagsWhenOff && payload.replyToTag) return payload;
|
||||
return { ...payload, replyToId: undefined };
|
||||
}
|
||||
if (mode === "all") return payload;
|
||||
@@ -34,3 +38,12 @@ export function createReplyToModeFilter(mode: ReplyToMode) {
|
||||
return payload;
|
||||
};
|
||||
}
|
||||
|
||||
export function createReplyToModeFilterForChannel(
|
||||
mode: ReplyToMode,
|
||||
channel?: OriginatingChannelType,
|
||||
) {
|
||||
return createReplyToModeFilter(mode, {
|
||||
allowTagsWhenOff: channel === "slack",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendMessageDiscord: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
|
||||
sendMessageIMessage: vi.fn(async () => ({ messageId: "ok" })),
|
||||
sendMessageMSTeams: vi.fn(async () => ({
|
||||
messageId: "m1",
|
||||
conversationId: "c1",
|
||||
})),
|
||||
sendMessageSignal: vi.fn(async () => ({ messageId: "t1" })),
|
||||
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
|
||||
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
|
||||
@@ -15,6 +22,9 @@ vi.mock("../../discord/send.js", () => ({
|
||||
vi.mock("../../imessage/send.js", () => ({
|
||||
sendMessageIMessage: mocks.sendMessageIMessage,
|
||||
}));
|
||||
vi.mock("../../msteams/send.js", () => ({
|
||||
sendMessageMSTeams: mocks.sendMessageMSTeams,
|
||||
}));
|
||||
vi.mock("../../signal/send.js", () => ({
|
||||
sendMessageSignal: mocks.sendMessageSignal,
|
||||
}));
|
||||
@@ -59,6 +69,63 @@ describe("routeReply", () => {
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops silent token payloads", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const res = await routeReply({
|
||||
payload: { text: SILENT_REPLY_TOKEN },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies responsePrefix when routing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
messages: { responsePrefix: "[clawdbot]" },
|
||||
} as unknown as ClawdbotConfig;
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"[clawdbot] hi",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives responsePrefix from agent identity when routing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "rich",
|
||||
identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" },
|
||||
},
|
||||
],
|
||||
},
|
||||
messages: {},
|
||||
} as unknown as ClawdbotConfig;
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
sessionKey: "agent:rich:main",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"[Richbot] hi",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes thread id to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
await routeReply({
|
||||
@@ -143,4 +210,25 @@ describe("routeReply", () => {
|
||||
expect.objectContaining({ accountId: "acc-1", verbose: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes MS Teams via proactive sender", async () => {
|
||||
mocks.sendMessageMSTeams.mockClear();
|
||||
const cfg = {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
},
|
||||
} as unknown as ClawdbotConfig;
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "msteams",
|
||||
to: "conversation:19:abc@thread.tacv2",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "conversation:19:abc@thread.tacv2",
|
||||
text: "hi",
|
||||
mediaUrl: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
* across multiple providers.
|
||||
*/
|
||||
|
||||
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { sendMessageDiscord } from "../../discord/send.js";
|
||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
||||
import { sendMessageMSTeams } from "../../msteams/send.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { sendMessageSignal } from "../../signal/send.js";
|
||||
import { sendMessageSlack } from "../../slack/send.js";
|
||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
|
||||
export type RouteReplyParams = {
|
||||
/** The reply payload to send. */
|
||||
@@ -24,6 +28,8 @@ export type RouteReplyParams = {
|
||||
channel: OriginatingChannelType;
|
||||
/** The destination chat/channel/user ID. */
|
||||
to: string;
|
||||
/** Session key for deriving agent identity defaults (multi-agent). */
|
||||
sessionKey?: string;
|
||||
/** Provider account id (multi-account). */
|
||||
accountId?: string;
|
||||
/** Telegram message thread id (forum topics). */
|
||||
@@ -54,16 +60,28 @@ export type RouteReplyResult = {
|
||||
export async function routeReply(
|
||||
params: RouteReplyParams,
|
||||
): Promise<RouteReplyResult> {
|
||||
const { payload, channel, to, accountId, threadId, abortSignal } = params;
|
||||
const { payload, channel, to, accountId, threadId, cfg, abortSignal } =
|
||||
params;
|
||||
|
||||
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
|
||||
const text = payload.text ?? "";
|
||||
const mediaUrls = (payload.mediaUrls?.filter(Boolean) ?? []).length
|
||||
? (payload.mediaUrls?.filter(Boolean) as string[])
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
const responsePrefix = params.sessionKey
|
||||
? resolveEffectiveMessagesConfig(
|
||||
cfg,
|
||||
resolveAgentIdFromSessionKey(params.sessionKey),
|
||||
).responsePrefix
|
||||
: cfg.messages?.responsePrefix;
|
||||
const normalized = normalizeReplyPayload(payload, {
|
||||
responsePrefix,
|
||||
});
|
||||
if (!normalized) return { ok: true };
|
||||
|
||||
const text = normalized.text ?? "";
|
||||
const mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
|
||||
? (normalized.mediaUrls?.filter(Boolean) as string[])
|
||||
: normalized.mediaUrl
|
||||
? [normalized.mediaUrl]
|
||||
: [];
|
||||
const replyToId = payload.replyToId;
|
||||
const replyToId = normalized.replyToId;
|
||||
|
||||
// Skip empty replies.
|
||||
if (!text.trim() && mediaUrls.length === 0) {
|
||||
@@ -145,6 +163,16 @@ export async function routeReply(
|
||||
};
|
||||
}
|
||||
|
||||
case "msteams": {
|
||||
const result = await sendMessageMSTeams({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
});
|
||||
return { ok: true, messageId: result.messageId };
|
||||
}
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = channel;
|
||||
return { ok: false, error: `Unknown channel: ${String(_exhaustive)}` };
|
||||
@@ -195,7 +223,8 @@ export function isRoutableChannel(
|
||||
| "discord"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "whatsapp" {
|
||||
| "whatsapp"
|
||||
| "msteams" {
|
||||
if (!channel) return false;
|
||||
return [
|
||||
"telegram",
|
||||
@@ -204,5 +233,6 @@ export function isRoutableChannel(
|
||||
"signal",
|
||||
"imessage",
|
||||
"whatsapp",
|
||||
"msteams",
|
||||
].includes(channel);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function ensureSkillSnapshot(params: {
|
||||
systemSent: true,
|
||||
skillsSnapshot: skillSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = nextEntry;
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export async function ensureSkillSnapshot(params: {
|
||||
updatedAt: Date.now(),
|
||||
skillsSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = nextEntry;
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ export async function initSessionState(params: {
|
||||
ctx.MessageThreadId,
|
||||
);
|
||||
}
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
|
||||
@@ -1,44 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { buildCommandsMessage, buildStatusMessage } from "./status.js";
|
||||
|
||||
const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const;
|
||||
type HomeEnvSnapshot = Record<
|
||||
(typeof HOME_ENV_KEYS)[number],
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
const snapshotHomeEnv = (): HomeEnvSnapshot => ({
|
||||
HOME: process.env.HOME,
|
||||
USERPROFILE: process.env.USERPROFILE,
|
||||
HOMEDRIVE: process.env.HOMEDRIVE,
|
||||
HOMEPATH: process.env.HOMEPATH,
|
||||
});
|
||||
|
||||
const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => {
|
||||
for (const key of HOME_ENV_KEYS) {
|
||||
const value = snapshot[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setTempHome = (tempHome: string) => {
|
||||
process.env.HOME = tempHome;
|
||||
if (process.platform === "win32") {
|
||||
process.env.USERPROFILE = tempHome;
|
||||
const root = path.parse(tempHome).root;
|
||||
process.env.HOMEDRIVE = root.replace(/\\$/, "");
|
||||
process.env.HOMEPATH = tempHome.slice(root.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -89,19 +56,22 @@ describe("buildStatusMessage", () => {
|
||||
modelAuth: "api-key",
|
||||
now: 10 * 60_000, // 10 minutes later
|
||||
});
|
||||
const normalized = normalizeTestText(text);
|
||||
|
||||
expect(text).toContain("🦞 ClawdBot");
|
||||
expect(text).toContain("🧠 Model: anthropic/pi:opus · 🔑 api-key");
|
||||
expect(text).toContain("🧮 Tokens: 1.2k in / 800 out · 💵 Cost: $0.0020");
|
||||
expect(text).toContain("Context: 16k/32k (50%)");
|
||||
expect(text).toContain("🧹 Compactions: 2");
|
||||
expect(text).toContain("Session: agent:main:main");
|
||||
expect(text).toContain("updated 10m ago");
|
||||
expect(text).toContain("Runtime: direct");
|
||||
expect(text).toContain("Think: medium");
|
||||
expect(text).toContain("Verbose: off");
|
||||
expect(text).toContain("Elevated: on");
|
||||
expect(text).toContain("Queue: collect");
|
||||
expect(normalized).toContain("ClawdBot");
|
||||
expect(normalized).toContain("Model: anthropic/pi:opus");
|
||||
expect(normalized).toContain("api-key");
|
||||
expect(normalized).toContain("Tokens: 1.2k in / 800 out");
|
||||
expect(normalized).toContain("Cost: $0.0020");
|
||||
expect(normalized).toContain("Context: 16k/32k (50%)");
|
||||
expect(normalized).toContain("Compactions: 2");
|
||||
expect(normalized).toContain("Session: agent:main:main");
|
||||
expect(normalized).toContain("updated 10m ago");
|
||||
expect(normalized).toContain("Runtime: direct");
|
||||
expect(normalized).toContain("Think: medium");
|
||||
expect(normalized).toContain("Verbose: off");
|
||||
expect(normalized).toContain("Elevated: on");
|
||||
expect(normalized).toContain("Queue: collect");
|
||||
});
|
||||
|
||||
it("shows verbose/elevated labels only when enabled", () => {
|
||||
@@ -141,7 +111,7 @@ describe("buildStatusMessage", () => {
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("🧠 Model: openai/gpt-4.1-mini");
|
||||
expect(normalizeTestText(text)).toContain("Model: openai/gpt-4.1-mini");
|
||||
});
|
||||
|
||||
it("keeps provider prefix from configured model", () => {
|
||||
@@ -154,7 +124,9 @@ describe("buildStatusMessage", () => {
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5");
|
||||
expect(normalizeTestText(text)).toContain(
|
||||
"Model: google-antigravity/claude-sonnet-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles missing agent config gracefully", () => {
|
||||
@@ -165,9 +137,10 @@ describe("buildStatusMessage", () => {
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("🧠 Model:");
|
||||
expect(text).toContain("Context:");
|
||||
expect(text).toContain("Queue: collect");
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Model:");
|
||||
expect(normalized).toContain("Context:");
|
||||
expect(normalized).toContain("Queue: collect");
|
||||
});
|
||||
|
||||
it("includes group activation for group sessions", () => {
|
||||
@@ -221,10 +194,10 @@ describe("buildStatusMessage", () => {
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
const lines = text.split("\n");
|
||||
const contextIndex = lines.findIndex((line) => line.startsWith("📚 "));
|
||||
const lines = normalizeTestText(text).split("\n");
|
||||
const contextIndex = lines.findIndex((line) => line.includes("Context:"));
|
||||
expect(contextIndex).toBeGreaterThan(-1);
|
||||
expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)");
|
||||
expect(lines[contextIndex + 1]).toContain("Usage: Claude 80% left (5h)");
|
||||
});
|
||||
|
||||
it("hides cost when not using an API key", () => {
|
||||
@@ -260,70 +233,67 @@ describe("buildStatusMessage", () => {
|
||||
});
|
||||
|
||||
it("prefers cached prompt tokens from the session log", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-status-"));
|
||||
const previousHome = snapshotHomeEnv();
|
||||
setTempHome(dir);
|
||||
try {
|
||||
vi.resetModules();
|
||||
const { buildStatusMessage: buildStatusMessageDynamic } = await import(
|
||||
"./status.js"
|
||||
);
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
vi.resetModules();
|
||||
const { buildStatusMessage: buildStatusMessageDynamic } = await import(
|
||||
"./status.js"
|
||||
);
|
||||
|
||||
const sessionId = "sess-1";
|
||||
const logPath = path.join(
|
||||
dir,
|
||||
".clawdbot",
|
||||
"agents",
|
||||
"main",
|
||||
"sessions",
|
||||
`${sessionId}.jsonl`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
const sessionId = "sess-1";
|
||||
const logPath = path.join(
|
||||
dir,
|
||||
".clawdbot",
|
||||
"agents",
|
||||
"main",
|
||||
"sessions",
|
||||
`${sessionId}.jsonl`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
logPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
model: "claude-opus-4-5",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 1000,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 1003,
|
||||
fs.writeFileSync(
|
||||
logPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
model: "claude-opus-4-5",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 1000,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 1003,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const text = buildStatusMessageDynamic({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
contextTokens: 32_000,
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId,
|
||||
updatedAt: 0,
|
||||
totalTokens: 3, // would be wrong if cached prompt tokens exist
|
||||
contextTokens: 32_000,
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
includeTranscriptUsage: true,
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
const text = buildStatusMessageDynamic({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
contextTokens: 32_000,
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId,
|
||||
updatedAt: 0,
|
||||
totalTokens: 3, // would be wrong if cached prompt tokens exist
|
||||
contextTokens: 32_000,
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
includeTranscriptUsage: true,
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("Context: 1.0k/32k");
|
||||
} finally {
|
||||
restoreHomeEnv(previousHome);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
expect(normalizeTestText(text)).toContain("Context: 1.0k/32k");
|
||||
},
|
||||
{ prefix: "clawdbot-status-" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ import type {
|
||||
VerboseLevel,
|
||||
} from "./thinking.js";
|
||||
|
||||
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
|
||||
type AgentConfig = Partial<
|
||||
NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
|
||||
>;
|
||||
|
||||
export const formatTokenCount = formatTokenCountShared;
|
||||
|
||||
@@ -189,7 +191,11 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const now = args.now ?? Date.now();
|
||||
const entry = args.sessionEntry;
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg: { agent: args.agent ?? {} },
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: args.agent ?? {},
|
||||
},
|
||||
} as ClawdbotConfig,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
@@ -352,7 +358,7 @@ export function buildHelpMessage(): string {
|
||||
return [
|
||||
"ℹ️ Help",
|
||||
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
|
||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off",
|
||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /debug show",
|
||||
"More: /commands for all slash commands",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export type OriginatingChannelType =
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "whatsapp"
|
||||
| "webchat";
|
||||
| "webchat"
|
||||
| "msteams";
|
||||
|
||||
export type MsgContext = {
|
||||
Body?: string;
|
||||
|
||||
@@ -37,8 +37,8 @@ describe("transcribeInboundAudio", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const cfg = {
|
||||
routing: {
|
||||
transcribeAudio: {
|
||||
audio: {
|
||||
transcription: {
|
||||
command: ["echo", "{{MediaPath}}"],
|
||||
timeoutSeconds: 5,
|
||||
},
|
||||
@@ -64,7 +64,7 @@ describe("transcribeInboundAudio", () => {
|
||||
it("returns undefined when no transcription command", async () => {
|
||||
const { transcribeInboundAudio } = await import("./transcription.js");
|
||||
const res = await transcribeInboundAudio(
|
||||
{ routing: {} } as never,
|
||||
{ audio: {} } as never,
|
||||
{} as never,
|
||||
runtime as never,
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function transcribeInboundAudio(
|
||||
ctx: MsgContext,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<{ text: string } | undefined> {
|
||||
const transcriber = cfg.routing?.transcribeAudio;
|
||||
const transcriber = cfg.audio?.transcription;
|
||||
if (!transcriber?.command?.length) return undefined;
|
||||
|
||||
const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000);
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ReplyPayload = {
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
replyToId?: string;
|
||||
replyToTag?: boolean;
|
||||
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
||||
audioAsVoice?: boolean;
|
||||
isError?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user