merge upstream/main
This commit is contained in:
@@ -1,6 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chunkText, resolveTextChunkLimit } from "./chunk.js";
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
chunkText,
|
||||
resolveTextChunkLimit,
|
||||
} from "./chunk.js";
|
||||
|
||||
function expectFencesBalanced(chunks: string[]) {
|
||||
for (const chunk of chunks) {
|
||||
let open: { markerChar: string; markerLen: number } | null = null;
|
||||
for (const line of chunk.split("\n")) {
|
||||
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
|
||||
if (!match) continue;
|
||||
const marker = match[2];
|
||||
if (!open) {
|
||||
open = { markerChar: marker[0], markerLen: marker.length };
|
||||
continue;
|
||||
}
|
||||
if (open.markerChar === marker[0] && marker.length >= open.markerLen) {
|
||||
open = null;
|
||||
}
|
||||
}
|
||||
expect(open).toBe(null);
|
||||
}
|
||||
}
|
||||
|
||||
describe("chunkText", () => {
|
||||
it("keeps multi-line text in one chunk when under limit", () => {
|
||||
@@ -47,7 +70,7 @@ describe("chunkText", () => {
|
||||
});
|
||||
|
||||
describe("resolveTextChunkLimit", () => {
|
||||
it("uses per-surface defaults", () => {
|
||||
it("uses per-provider defaults", () => {
|
||||
expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000);
|
||||
expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000);
|
||||
expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000);
|
||||
@@ -72,3 +95,79 @@ describe("resolveTextChunkLimit", () => {
|
||||
expect(resolveTextChunkLimit(cfg, "telegram")).toBe(4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkMarkdownText", () => {
|
||||
it("keeps fenced blocks intact when a safe break exists", () => {
|
||||
const prefix = "p".repeat(60);
|
||||
const fence = "```bash\nline1\nline2\n```";
|
||||
const suffix = "s".repeat(60);
|
||||
const text = `${prefix}\n\n${fence}\n\n${suffix}`;
|
||||
|
||||
const chunks = chunkMarkdownText(text, 40);
|
||||
expect(chunks.some((chunk) => chunk.trimEnd() === fence)).toBe(true);
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
|
||||
it("reopens fenced blocks when forced to split inside them", () => {
|
||||
const text = `\`\`\`txt\n${"a".repeat(500)}\n\`\`\``;
|
||||
const limit = 120;
|
||||
const chunks = chunkMarkdownText(text, limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(limit);
|
||||
expect(chunk.startsWith("```txt\n")).toBe(true);
|
||||
expect(chunk.trimEnd().endsWith("```")).toBe(true);
|
||||
}
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
|
||||
it("supports tilde fences", () => {
|
||||
const text = `~~~sh\n${"x".repeat(600)}\n~~~`;
|
||||
const limit = 140;
|
||||
const chunks = chunkMarkdownText(text, limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(limit);
|
||||
expect(chunk.startsWith("~~~sh\n")).toBe(true);
|
||||
expect(chunk.trimEnd().endsWith("~~~")).toBe(true);
|
||||
}
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
|
||||
it("supports longer fence markers for close", () => {
|
||||
const text = `\`\`\`\`md\n${"y".repeat(600)}\n\`\`\`\``;
|
||||
const limit = 140;
|
||||
const chunks = chunkMarkdownText(text, limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(limit);
|
||||
expect(chunk.startsWith("````md\n")).toBe(true);
|
||||
expect(chunk.trimEnd().endsWith("````")).toBe(true);
|
||||
}
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
|
||||
it("preserves indentation for indented fences", () => {
|
||||
const text = ` \`\`\`js\n ${"z".repeat(600)}\n \`\`\``;
|
||||
const limit = 160;
|
||||
const chunks = chunkMarkdownText(text, limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(limit);
|
||||
expect(chunk.startsWith(" ```js\n")).toBe(true);
|
||||
expect(chunk.trimEnd().endsWith(" ```")).toBe(true);
|
||||
}
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
|
||||
it("never produces an empty fenced chunk when splitting", () => {
|
||||
const text = `\`\`\`txt\n${"a".repeat(300)}\n\`\`\``;
|
||||
const chunks = chunkMarkdownText(text, 60);
|
||||
for (const chunk of chunks) {
|
||||
const nonFenceLines = chunk
|
||||
.split("\n")
|
||||
.filter((line) => !/^( {0,3})(`{3,}|~{3,})(.*)$/.test(line));
|
||||
expect(nonFenceLines.join("\n").trim()).not.toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
// the chunk so messages are only split when they truly exceed the limit.
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
findFenceSpanAt,
|
||||
isSafeFenceBreak,
|
||||
parseFenceSpans,
|
||||
} from "../markdown/fences.js";
|
||||
|
||||
export type TextChunkSurface =
|
||||
export type TextChunkProvider =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
@@ -13,7 +18,7 @@ export type TextChunkSurface =
|
||||
| "imessage"
|
||||
| "webchat";
|
||||
|
||||
const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record<TextChunkSurface, number> = {
|
||||
const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
|
||||
whatsapp: 4000,
|
||||
telegram: 4000,
|
||||
discord: 2000,
|
||||
@@ -25,22 +30,22 @@ const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record<TextChunkSurface, number> = {
|
||||
|
||||
export function resolveTextChunkLimit(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
surface?: TextChunkSurface,
|
||||
provider?: TextChunkProvider,
|
||||
): number {
|
||||
const surfaceOverride = (() => {
|
||||
if (!surface) return undefined;
|
||||
if (surface === "whatsapp") return cfg?.whatsapp?.textChunkLimit;
|
||||
if (surface === "telegram") return cfg?.telegram?.textChunkLimit;
|
||||
if (surface === "discord") return cfg?.discord?.textChunkLimit;
|
||||
if (surface === "slack") return cfg?.slack?.textChunkLimit;
|
||||
if (surface === "signal") return cfg?.signal?.textChunkLimit;
|
||||
if (surface === "imessage") return cfg?.imessage?.textChunkLimit;
|
||||
const providerOverride = (() => {
|
||||
if (!provider) return undefined;
|
||||
if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit;
|
||||
if (provider === "telegram") return cfg?.telegram?.textChunkLimit;
|
||||
if (provider === "discord") return cfg?.discord?.textChunkLimit;
|
||||
if (provider === "slack") return cfg?.slack?.textChunkLimit;
|
||||
if (provider === "signal") return cfg?.signal?.textChunkLimit;
|
||||
if (provider === "imessage") return cfg?.imessage?.textChunkLimit;
|
||||
return undefined;
|
||||
})();
|
||||
if (typeof surfaceOverride === "number" && surfaceOverride > 0) {
|
||||
return surfaceOverride;
|
||||
if (typeof providerOverride === "number" && providerOverride > 0) {
|
||||
return providerOverride;
|
||||
}
|
||||
if (surface) return DEFAULT_CHUNK_LIMIT_BY_SURFACE[surface];
|
||||
if (provider) return DEFAULT_CHUNK_LIMIT_BY_PROVIDER[provider];
|
||||
return 4000;
|
||||
}
|
||||
|
||||
@@ -91,3 +96,123 @@ export function chunkText(text: string, limit: number): string[] {
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function chunkMarkdownText(text: string, limit: number): string[] {
|
||||
if (!text) return [];
|
||||
if (limit <= 0) return [text];
|
||||
if (text.length <= limit) return [text];
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > limit) {
|
||||
const spans = parseFenceSpans(remaining);
|
||||
const window = remaining.slice(0, limit);
|
||||
|
||||
const softBreak = pickSafeBreakIndex(window, spans);
|
||||
let breakIdx = softBreak > 0 ? softBreak : limit;
|
||||
|
||||
const initialFence = isSafeFenceBreak(spans, breakIdx)
|
||||
? undefined
|
||||
: findFenceSpanAt(spans, breakIdx);
|
||||
|
||||
let fenceToSplit = initialFence;
|
||||
if (initialFence) {
|
||||
const closeLine = `${initialFence.indent}${initialFence.marker}`;
|
||||
const maxIdxIfNeedNewline = limit - (closeLine.length + 1);
|
||||
|
||||
if (maxIdxIfNeedNewline <= 0) {
|
||||
fenceToSplit = undefined;
|
||||
breakIdx = limit;
|
||||
} else {
|
||||
const minProgressIdx = Math.min(
|
||||
remaining.length,
|
||||
initialFence.start + initialFence.openLine.length + 2,
|
||||
);
|
||||
const maxIdxIfAlreadyNewline = limit - closeLine.length;
|
||||
|
||||
let pickedNewline = false;
|
||||
let lastNewline = remaining.lastIndexOf(
|
||||
"\n",
|
||||
Math.max(0, maxIdxIfAlreadyNewline - 1),
|
||||
);
|
||||
while (lastNewline !== -1) {
|
||||
const candidateBreak = lastNewline + 1;
|
||||
if (candidateBreak < minProgressIdx) break;
|
||||
const candidateFence = findFenceSpanAt(spans, candidateBreak);
|
||||
if (candidateFence && candidateFence.start === initialFence.start) {
|
||||
breakIdx = Math.max(1, candidateBreak);
|
||||
pickedNewline = true;
|
||||
break;
|
||||
}
|
||||
lastNewline = remaining.lastIndexOf("\n", lastNewline - 1);
|
||||
}
|
||||
|
||||
if (!pickedNewline) {
|
||||
if (minProgressIdx > maxIdxIfAlreadyNewline) {
|
||||
fenceToSplit = undefined;
|
||||
breakIdx = limit;
|
||||
} else {
|
||||
breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fenceAtBreak = findFenceSpanAt(spans, breakIdx);
|
||||
fenceToSplit =
|
||||
fenceAtBreak && fenceAtBreak.start === initialFence.start
|
||||
? fenceAtBreak
|
||||
: undefined;
|
||||
}
|
||||
|
||||
let rawChunk = remaining.slice(0, breakIdx);
|
||||
if (!rawChunk) break;
|
||||
|
||||
const brokeOnSeparator =
|
||||
breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||
const nextStart = Math.min(
|
||||
remaining.length,
|
||||
breakIdx + (brokeOnSeparator ? 1 : 0),
|
||||
);
|
||||
let next = remaining.slice(nextStart);
|
||||
|
||||
if (fenceToSplit) {
|
||||
const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`;
|
||||
rawChunk = rawChunk.endsWith("\n")
|
||||
? `${rawChunk}${closeLine}`
|
||||
: `${rawChunk}\n${closeLine}`;
|
||||
next = `${fenceToSplit.openLine}\n${next}`;
|
||||
} else {
|
||||
next = stripLeadingNewlines(next);
|
||||
}
|
||||
|
||||
chunks.push(rawChunk);
|
||||
remaining = next;
|
||||
}
|
||||
|
||||
if (remaining.length) chunks.push(remaining);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function stripLeadingNewlines(value: string): string {
|
||||
let i = 0;
|
||||
while (i < value.length && value[i] === "\n") i++;
|
||||
return i > 0 ? value.slice(i) : value;
|
||||
}
|
||||
|
||||
function pickSafeBreakIndex(
|
||||
window: string,
|
||||
spans: ReturnType<typeof parseFenceSpans>,
|
||||
): number {
|
||||
let newlineIdx = window.lastIndexOf("\n");
|
||||
while (newlineIdx > 0) {
|
||||
if (isSafeFenceBreak(spans, newlineIdx)) return newlineIdx;
|
||||
newlineIdx = window.lastIndexOf("\n", newlineIdx - 1);
|
||||
}
|
||||
|
||||
for (let i = window.length - 1; i > 0; i--) {
|
||||
if (/\s/.test(window[i]) && isSafeFenceBreak(spans, i)) return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { normalizeE164 } from "../utils.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
|
||||
export type CommandAuthorization = {
|
||||
isWhatsAppSurface: boolean;
|
||||
isWhatsAppProvider: boolean;
|
||||
ownerList: string[];
|
||||
senderE164?: string;
|
||||
isAuthorizedSender: boolean;
|
||||
@@ -17,7 +17,7 @@ export function resolveCommandAuthorization(params: {
|
||||
commandAuthorized: boolean;
|
||||
}): CommandAuthorization {
|
||||
const { ctx, cfg, commandAuthorized } = params;
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const provider = (ctx.Provider ?? "").trim().toLowerCase();
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const hasWhatsappPrefix =
|
||||
@@ -26,30 +26,30 @@ export function resolveCommandAuthorization(params: {
|
||||
const looksLikeE164 = (value: string) =>
|
||||
Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, "")));
|
||||
const inferWhatsApp =
|
||||
!surface &&
|
||||
!provider &&
|
||||
Boolean(cfg.whatsapp?.allowFrom?.length) &&
|
||||
(looksLikeE164(from) || looksLikeE164(to));
|
||||
const isWhatsAppSurface =
|
||||
surface === "whatsapp" || hasWhatsappPrefix || inferWhatsApp;
|
||||
const isWhatsAppProvider =
|
||||
provider === "whatsapp" || hasWhatsappPrefix || inferWhatsApp;
|
||||
|
||||
const configuredAllowFrom = isWhatsAppSurface
|
||||
const configuredAllowFrom = isWhatsAppProvider
|
||||
? cfg.whatsapp?.allowFrom
|
||||
: undefined;
|
||||
const allowFromList =
|
||||
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
|
||||
const allowAll =
|
||||
!isWhatsAppSurface ||
|
||||
!isWhatsAppProvider ||
|
||||
allowFromList.length === 0 ||
|
||||
allowFromList.some((entry) => entry.trim() === "*");
|
||||
|
||||
const senderE164 = normalizeE164(
|
||||
ctx.SenderE164 ?? (isWhatsAppSurface ? from : ""),
|
||||
ctx.SenderE164 ?? (isWhatsAppProvider ? from : ""),
|
||||
);
|
||||
const ownerCandidates =
|
||||
isWhatsAppSurface && !allowAll
|
||||
isWhatsAppProvider && !allowAll
|
||||
? allowFromList.filter((entry) => entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) {
|
||||
if (isWhatsAppProvider && !allowAll && ownerCandidates.length === 0 && to) {
|
||||
ownerCandidates.push(to);
|
||||
}
|
||||
const ownerList = ownerCandidates
|
||||
@@ -57,14 +57,14 @@ export function resolveCommandAuthorization(params: {
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
const isOwner =
|
||||
!isWhatsAppSurface ||
|
||||
!isWhatsAppProvider ||
|
||||
allowAll ||
|
||||
ownerList.length === 0 ||
|
||||
(senderE164 ? ownerList.includes(senderE164) : false);
|
||||
const isAuthorizedSender = commandAuthorized && isOwner;
|
||||
|
||||
return {
|
||||
isWhatsAppSurface,
|
||||
isWhatsAppProvider,
|
||||
ownerList,
|
||||
senderE164: senderE164 || undefined,
|
||||
isAuthorizedSender,
|
||||
|
||||
42
src/auto-reply/command-detection.test.ts
Normal file
42
src/auto-reply/command-detection.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasControlCommand } from "./command-detection.js";
|
||||
import { parseActivationCommand } from "./group-activation.js";
|
||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||
|
||||
describe("control command parsing", () => {
|
||||
it("requires slash for send policy", () => {
|
||||
expect(parseSendPolicyCommand("/send on")).toEqual({
|
||||
hasCommand: true,
|
||||
mode: "allow",
|
||||
});
|
||||
expect(parseSendPolicyCommand("/send")).toEqual({ hasCommand: true });
|
||||
expect(parseSendPolicyCommand("send on")).toEqual({ hasCommand: false });
|
||||
expect(parseSendPolicyCommand("send")).toEqual({ hasCommand: false });
|
||||
});
|
||||
|
||||
it("requires slash for activation", () => {
|
||||
expect(parseActivationCommand("/activation mention")).toEqual({
|
||||
hasCommand: true,
|
||||
mode: "mention",
|
||||
});
|
||||
expect(parseActivationCommand("activation mention")).toEqual({
|
||||
hasCommand: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats bare commands as non-control", () => {
|
||||
expect(hasControlCommand("/send")).toBe(true);
|
||||
expect(hasControlCommand("send")).toBe(false);
|
||||
expect(hasControlCommand("/help")).toBe(true);
|
||||
expect(hasControlCommand("help")).toBe(false);
|
||||
expect(hasControlCommand("/status")).toBe(true);
|
||||
expect(hasControlCommand("status")).toBe(false);
|
||||
});
|
||||
|
||||
it("requires commands to be the full message", () => {
|
||||
expect(hasControlCommand("hello /status")).toBe(false);
|
||||
expect(hasControlCommand("/status please")).toBe(false);
|
||||
expect(hasControlCommand("prefix /send on")).toBe(false);
|
||||
expect(hasControlCommand("/send on")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,20 @@
|
||||
const CONTROL_COMMAND_RE =
|
||||
/(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)(?=$|\s|:)\b/i;
|
||||
|
||||
const CONTROL_COMMAND_EXACT = new Set([
|
||||
"help",
|
||||
"/help",
|
||||
"status",
|
||||
"/status",
|
||||
"restart",
|
||||
"/restart",
|
||||
"activation",
|
||||
"/activation",
|
||||
"send",
|
||||
"/send",
|
||||
"reset",
|
||||
"/reset",
|
||||
"new",
|
||||
"/new",
|
||||
"compact",
|
||||
"/compact",
|
||||
]);
|
||||
import { listChatCommands } from "./commands-registry.js";
|
||||
|
||||
export function hasControlCommand(text?: string): boolean {
|
||||
if (!text) return false;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (CONTROL_COMMAND_EXACT.has(lowered)) return true;
|
||||
return CONTROL_COMMAND_RE.test(text);
|
||||
for (const command of listChatCommands()) {
|
||||
for (const alias of command.textAliases) {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
if (lowered === normalized) return true;
|
||||
if (command.acceptsArgs && lowered.startsWith(normalized)) {
|
||||
const nextChar = trimmed.charAt(normalized.length);
|
||||
if (nextChar && /\s/.test(nextChar)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
52
src/auto-reply/commands-registry.test.ts
Normal file
52
src/auto-reply/commands-registry.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCommandText,
|
||||
getCommandDetection,
|
||||
listNativeCommandSpecs,
|
||||
shouldHandleTextCommands,
|
||||
} from "./commands-registry.js";
|
||||
|
||||
describe("commands registry", () => {
|
||||
it("builds command text with args", () => {
|
||||
expect(buildCommandText("status")).toBe("/status");
|
||||
expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5");
|
||||
});
|
||||
|
||||
it("exposes native specs", () => {
|
||||
const specs = listNativeCommandSpecs();
|
||||
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("detects known text commands", () => {
|
||||
const detection = getCommandDetection();
|
||||
expect(detection.exact.has("/help")).toBe(true);
|
||||
expect(detection.regex.test("/status")).toBe(true);
|
||||
expect(detection.regex.test("try /status")).toBe(false);
|
||||
});
|
||||
|
||||
it("respects text command gating", () => {
|
||||
const cfg = { commands: { text: false } };
|
||||
expect(
|
||||
shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "discord",
|
||||
commandSource: "text",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "whatsapp",
|
||||
commandSource: "text",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "discord",
|
||||
commandSource: "native",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
178
src/auto-reply/commands-registry.ts
Normal file
178
src/auto-reply/commands-registry.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
|
||||
export type ChatCommandDefinition = {
|
||||
key: string;
|
||||
nativeName: string;
|
||||
description: string;
|
||||
textAliases: string[];
|
||||
acceptsArgs?: boolean;
|
||||
};
|
||||
|
||||
export type NativeCommandSpec = {
|
||||
name: string;
|
||||
description: string;
|
||||
acceptsArgs: boolean;
|
||||
};
|
||||
|
||||
const CHAT_COMMANDS: ChatCommandDefinition[] = [
|
||||
{
|
||||
key: "help",
|
||||
nativeName: "help",
|
||||
description: "Show available commands.",
|
||||
textAliases: ["/help"],
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
nativeName: "status",
|
||||
description: "Show current status.",
|
||||
textAliases: ["/status"],
|
||||
},
|
||||
{
|
||||
key: "restart",
|
||||
nativeName: "restart",
|
||||
description: "Restart Clawdbot.",
|
||||
textAliases: ["/restart"],
|
||||
},
|
||||
{
|
||||
key: "activation",
|
||||
nativeName: "activation",
|
||||
description: "Set group activation mode.",
|
||||
textAliases: ["/activation"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "send",
|
||||
nativeName: "send",
|
||||
description: "Set send policy.",
|
||||
textAliases: ["/send"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
nativeName: "reset",
|
||||
description: "Reset the current session.",
|
||||
textAliases: ["/reset"],
|
||||
},
|
||||
{
|
||||
key: "new",
|
||||
nativeName: "new",
|
||||
description: "Start a new session.",
|
||||
textAliases: ["/new"],
|
||||
},
|
||||
{
|
||||
key: "think",
|
||||
nativeName: "think",
|
||||
description: "Set thinking level.",
|
||||
textAliases: ["/thinking", "/think", "/t"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "verbose",
|
||||
nativeName: "verbose",
|
||||
description: "Toggle verbose mode.",
|
||||
textAliases: ["/verbose", "/v"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "elevated",
|
||||
nativeName: "elevated",
|
||||
description: "Toggle elevated mode.",
|
||||
textAliases: ["/elevated", "/elev"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Show or set the model.",
|
||||
textAliases: ["/model"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "queue",
|
||||
nativeName: "queue",
|
||||
description: "Adjust queue settings.",
|
||||
textAliases: ["/queue"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
];
|
||||
|
||||
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
|
||||
|
||||
let cachedDetection:
|
||||
| {
|
||||
exact: Set<string>;
|
||||
regex: RegExp;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function listChatCommands(): ChatCommandDefinition[] {
|
||||
return [...CHAT_COMMANDS];
|
||||
}
|
||||
|
||||
export function listNativeCommandSpecs(): NativeCommandSpec[] {
|
||||
return CHAT_COMMANDS.map((command) => ({
|
||||
name: command.nativeName,
|
||||
description: command.description,
|
||||
acceptsArgs: Boolean(command.acceptsArgs),
|
||||
}));
|
||||
}
|
||||
|
||||
export function findCommandByNativeName(
|
||||
name: string,
|
||||
): ChatCommandDefinition | undefined {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return CHAT_COMMANDS.find(
|
||||
(command) => command.nativeName.toLowerCase() === normalized,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCommandText(commandName: string, args?: string): string {
|
||||
const trimmedArgs = args?.trim();
|
||||
return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`;
|
||||
}
|
||||
|
||||
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
||||
if (cachedDetection) return cachedDetection;
|
||||
const exact = new Set<string>();
|
||||
const patterns: string[] = [];
|
||||
for (const command of CHAT_COMMANDS) {
|
||||
for (const alias of command.textAliases) {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
exact.add(normalized);
|
||||
const escaped = escapeRegExp(normalized);
|
||||
if (!escaped) continue;
|
||||
if (command.acceptsArgs) {
|
||||
patterns.push(`${escaped}(?:\\s+.+)?`);
|
||||
} else {
|
||||
patterns.push(escaped);
|
||||
}
|
||||
}
|
||||
}
|
||||
const regex = patterns.length
|
||||
? new RegExp(`^(?:${patterns.join("|")})$`, "i")
|
||||
: /$^/;
|
||||
cachedDetection = { exact, regex };
|
||||
return cachedDetection;
|
||||
}
|
||||
|
||||
export function supportsNativeCommands(surface?: string): boolean {
|
||||
if (!surface) return false;
|
||||
return NATIVE_COMMAND_SURFACES.has(surface.toLowerCase());
|
||||
}
|
||||
|
||||
export function shouldHandleTextCommands(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
surface?: string;
|
||||
commandSource?: "text" | "native";
|
||||
}): boolean {
|
||||
const { cfg, surface, commandSource } = params;
|
||||
const textEnabled = cfg.commands?.text !== false;
|
||||
if (commandSource === "native") return true;
|
||||
if (textEnabled) return true;
|
||||
return !supportsNativeCommands(surface);
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { formatAgentEnvelope } from "./envelope.js";
|
||||
|
||||
describe("formatAgentEnvelope", () => {
|
||||
it("includes surface, from, ip, host, and timestamp", () => {
|
||||
it("includes provider, from, ip, host, and timestamp", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "UTC";
|
||||
|
||||
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
|
||||
const body = formatAgentEnvelope({
|
||||
surface: "WebChat",
|
||||
provider: "WebChat",
|
||||
from: "user1",
|
||||
host: "mac-mini",
|
||||
ip: "10.0.0.5",
|
||||
@@ -30,7 +30,7 @@ describe("formatAgentEnvelope", () => {
|
||||
|
||||
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
|
||||
const body = formatAgentEnvelope({
|
||||
surface: "WebChat",
|
||||
provider: "WebChat",
|
||||
timestamp: ts,
|
||||
body: "hello",
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe("formatAgentEnvelope", () => {
|
||||
});
|
||||
|
||||
it("handles missing optional fields", () => {
|
||||
const body = formatAgentEnvelope({ surface: "Telegram", body: "hi" });
|
||||
const body = formatAgentEnvelope({ provider: "Telegram", body: "hi" });
|
||||
expect(body).toBe("[Telegram] hi");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type AgentEnvelopeParams = {
|
||||
surface: string;
|
||||
provider: string;
|
||||
from?: string;
|
||||
timestamp?: number | Date;
|
||||
host?: string;
|
||||
@@ -24,8 +24,8 @@ function formatTimestamp(ts?: number | Date): string | undefined {
|
||||
}
|
||||
|
||||
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||
const surface = params.surface?.trim() || "Surface";
|
||||
const parts: string[] = [surface];
|
||||
const provider = params.provider?.trim() || "Provider";
|
||||
const parts: string[] = [provider];
|
||||
if (params.from?.trim()) parts.push(params.from.trim());
|
||||
if (params.host?.trim()) parts.push(params.host.trim());
|
||||
if (params.ip?.trim()) parts.push(params.ip.trim());
|
||||
|
||||
@@ -16,7 +16,7 @@ export function parseActivationCommand(raw?: string): {
|
||||
if (!raw) return { hasCommand: false };
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { hasCommand: false };
|
||||
const match = trimmed.match(/^\/?activation\b(?:\s+([a-zA-Z]+))?/i);
|
||||
const match = trimmed.match(/^\/activation(?:\s+([a-zA-Z]+))?\s*$/i);
|
||||
if (!match) return { hasCommand: false };
|
||||
const mode = normalizeGroupActivation(match[1]);
|
||||
return { hasCommand: true, mode };
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
||||
export const HEARTBEAT_PROMPT =
|
||||
"Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.";
|
||||
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 30;
|
||||
|
||||
export function resolveHeartbeatPrompt(raw?: string): string {
|
||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||
return trimmed || HEARTBEAT_PROMPT;
|
||||
}
|
||||
|
||||
export type StripHeartbeatMode = "heartbeat" | "message";
|
||||
|
||||
function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("block streaming", () => {
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
MessageSid: "msg-123",
|
||||
Surface: "discord",
|
||||
Provider: "discord",
|
||||
},
|
||||
{
|
||||
onReplyStart,
|
||||
@@ -124,7 +124,7 @@ describe("block streaming", () => {
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
MessageSid: "msg-124",
|
||||
Surface: "discord",
|
||||
Provider: "discord",
|
||||
},
|
||||
{
|
||||
onBlockReply,
|
||||
|
||||
@@ -321,7 +321,7 @@ describe("directive parsing", () => {
|
||||
Body: "/elevated maybe",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
},
|
||||
{},
|
||||
@@ -512,7 +512,7 @@ describe("directive parsing", () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
const ctx = {
|
||||
Body: "please do the thing /verbose on",
|
||||
Body: "please do the thing",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
};
|
||||
@@ -546,6 +546,21 @@ describe("directive parsing", () => {
|
||||
};
|
||||
});
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/verbose on", From: ctx.From, To: ctx.To },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
ctx,
|
||||
{},
|
||||
@@ -709,7 +724,7 @@ describe("directive parsing", () => {
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model set to openai/gpt-4.1-mini");
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store.main;
|
||||
const entry = store["agent:main:main"];
|
||||
expect(entry.modelOverride).toBe("gpt-4.1-mini");
|
||||
expect(entry.providerOverride).toBe("openai");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
@@ -741,7 +756,7 @@ describe("directive parsing", () => {
|
||||
expect(text).toContain("Model set to Opus");
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store.main;
|
||||
const entry = store["agent:main:main"];
|
||||
expect(entry.modelOverride).toBe("claude-opus-4-5");
|
||||
expect(entry.providerOverride).toBe("anthropic");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
@@ -791,7 +806,7 @@ describe("directive parsing", () => {
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Auth profile set to anthropic:work");
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store.main;
|
||||
const entry = store["agent:main:main"];
|
||||
expect(entry.authProfileOverride).toBe("anthropic:work");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -827,7 +842,7 @@ describe("directive parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses model override for inline /model", async () => {
|
||||
it("ignores inline /model and uses the default model", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
@@ -867,8 +882,8 @@ describe("directive parsing", () => {
|
||||
expect(texts).toContain("done");
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
|
||||
expect(call?.provider).toBe("openai");
|
||||
expect(call?.model).toBe("gpt-4.1-mini");
|
||||
expect(call?.provider).toBe("anthropic");
|
||||
expect(call?.model).toBe("claude-opus-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -932,7 +947,7 @@ describe("directive parsing", () => {
|
||||
Body: "hello",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1004",
|
||||
},
|
||||
{},
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => {
|
||||
const onReplyStart = vi.fn();
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
|
||||
{ Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" },
|
||||
{ onReplyStart, isHeartbeat: false },
|
||||
makeCfg(home),
|
||||
);
|
||||
@@ -100,7 +100,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => {
|
||||
const onReplyStart = vi.fn();
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
|
||||
{ Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" },
|
||||
{ onReplyStart, isHeartbeat: true },
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
const MAIN_SESSION_KEY = "agent:main:main";
|
||||
|
||||
const webMocks = vi.hoisted(() => ({
|
||||
webAuthExists: vi.fn().mockResolvedValue(true),
|
||||
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
|
||||
@@ -113,8 +115,15 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports status when /status appears inline", async () => {
|
||||
it("ignores inline /status and runs the agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "please /status now",
|
||||
@@ -125,8 +134,8 @@ describe("trigger handling", () => {
|
||||
makeCfg(home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Status");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
expect(text).not.toContain("Status");
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +175,7 @@ describe("trigger handling", () => {
|
||||
Body: "/send off",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
},
|
||||
{},
|
||||
@@ -180,7 +189,7 @@ describe("trigger handling", () => {
|
||||
string,
|
||||
{ sendPolicy?: string }
|
||||
>;
|
||||
expect(store.main?.sendPolicy).toBe("deny");
|
||||
expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,7 +214,7 @@ describe("trigger handling", () => {
|
||||
Body: "/elevated on",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
},
|
||||
{},
|
||||
@@ -219,7 +228,7 @@ describe("trigger handling", () => {
|
||||
string,
|
||||
{ elevatedLevel?: string }
|
||||
>;
|
||||
expect(store.main?.elevatedLevel).toBe("on");
|
||||
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,7 +254,7 @@ describe("trigger handling", () => {
|
||||
Body: "/elevated on",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
},
|
||||
{},
|
||||
@@ -259,12 +268,19 @@ describe("trigger handling", () => {
|
||||
string,
|
||||
{ elevatedLevel?: string }
|
||||
>;
|
||||
expect(store.main?.elevatedLevel).toBeUndefined();
|
||||
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects elevated inline directive for unapproved sender", async () => {
|
||||
it("ignores inline elevated directive for unapproved sender", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
@@ -284,15 +300,15 @@ describe("trigger handling", () => {
|
||||
Body: "please /elevated on now",
|
||||
From: "+2000",
|
||||
To: "+2000",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+2000",
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("elevated is not available right now.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
expect(text).not.toBe("elevated is not available right now.");
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,7 +332,7 @@ describe("trigger handling", () => {
|
||||
Body: "/elevated on",
|
||||
From: "discord:123",
|
||||
To: "user:123",
|
||||
Surface: "discord",
|
||||
Provider: "discord",
|
||||
SenderName: "Peter Steinberger",
|
||||
SenderUsername: "steipete",
|
||||
SenderTag: "steipete",
|
||||
@@ -332,7 +348,7 @@ describe("trigger handling", () => {
|
||||
string,
|
||||
{ elevatedLevel?: string }
|
||||
>;
|
||||
expect(store.main?.elevatedLevel).toBe("on");
|
||||
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,7 +375,7 @@ describe("trigger handling", () => {
|
||||
Body: "/elevated on",
|
||||
From: "discord:123",
|
||||
To: "user:123",
|
||||
Surface: "discord",
|
||||
Provider: "discord",
|
||||
SenderName: "steipete",
|
||||
},
|
||||
{},
|
||||
@@ -510,7 +526,7 @@ describe("trigger handling", () => {
|
||||
From: "123@g.us",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+2000",
|
||||
},
|
||||
{},
|
||||
@@ -521,7 +537,9 @@ describe("trigger handling", () => {
|
||||
const store = JSON.parse(
|
||||
await fs.readFile(cfg.session.store, "utf-8"),
|
||||
) as Record<string, { groupActivation?: string }>;
|
||||
expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always");
|
||||
expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe(
|
||||
"always",
|
||||
);
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -535,7 +553,7 @@ describe("trigger handling", () => {
|
||||
From: "123@g.us",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+999",
|
||||
},
|
||||
{},
|
||||
@@ -563,7 +581,7 @@ describe("trigger handling", () => {
|
||||
From: "123@g.us",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+2000",
|
||||
GroupSubject: "Test Group",
|
||||
GroupMembers: "Alice (+1), Bob (+2)",
|
||||
@@ -879,7 +897,7 @@ describe("trigger handling", () => {
|
||||
From: "group:whatsapp:demo",
|
||||
To: "+2000",
|
||||
ChatType: "group" as const,
|
||||
Surface: "whatsapp" as const,
|
||||
Provider: "whatsapp" as const,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: "image/jpeg",
|
||||
MediaUrl: mediaPath,
|
||||
@@ -942,7 +960,7 @@ describe("group intro prompts", () => {
|
||||
ChatType: "group",
|
||||
GroupSubject: "Release Squad",
|
||||
GroupMembers: "Alice, Bob",
|
||||
Surface: "discord",
|
||||
Provider: "discord",
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
@@ -975,7 +993,7 @@ describe("group intro prompts", () => {
|
||||
To: "+1999",
|
||||
ChatType: "group",
|
||||
GroupSubject: "Ops",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
@@ -1008,7 +1026,7 @@ describe("group intro prompts", () => {
|
||||
To: "+1777",
|
||||
ChatType: "group",
|
||||
GroupSubject: "Dev Chat",
|
||||
Surface: "telegram",
|
||||
Provider: "telegram",
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
|
||||
@@ -2,7 +2,11 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentWorkspaceDir,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { resolveModelRefFromString } from "../agents/model-selection.js";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
@@ -11,6 +15,7 @@ import {
|
||||
resolveEmbeddedSessionLane,
|
||||
} from "../agents/pi-embedded.js";
|
||||
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||
import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
ensureAgentWorkspace,
|
||||
@@ -26,6 +31,7 @@ import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveCommandAuthorization } from "./command-auth.js";
|
||||
import { hasControlCommand } from "./command-detection.js";
|
||||
import { shouldHandleTextCommands } from "./commands-registry.js";
|
||||
import { getAbortMemory } from "./reply/abort.js";
|
||||
import { runReplyAgent } from "./reply/agent-runner.js";
|
||||
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
|
||||
@@ -33,6 +39,7 @@ import { applySessionHints } from "./reply/body.js";
|
||||
import { buildCommandContext, handleCommands } from "./reply/commands.js";
|
||||
import {
|
||||
handleDirectiveOnly,
|
||||
type InlineDirectives,
|
||||
isDirectiveOnly,
|
||||
parseInlineDirectives,
|
||||
persistInlineDirectives,
|
||||
@@ -43,7 +50,7 @@ import {
|
||||
defaultGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
} from "./reply/groups.js";
|
||||
import { stripMentions } from "./reply/mentions.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./reply/mentions.js";
|
||||
import {
|
||||
createModelSelectionState,
|
||||
resolveContextTokens,
|
||||
@@ -78,9 +85,6 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||
const BARE_SESSION_RESET_PROMPT =
|
||||
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
|
||||
|
||||
const CONTROL_COMMAND_PREFIX_RE =
|
||||
/^\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)\b/i;
|
||||
|
||||
function normalizeAllowToken(value?: string) {
|
||||
if (!value) return "";
|
||||
return value.trim().toLowerCase();
|
||||
@@ -107,10 +111,10 @@ function stripSenderPrefix(value?: string) {
|
||||
|
||||
function resolveElevatedAllowList(
|
||||
allowFrom: AgentElevatedAllowFromConfig | undefined,
|
||||
surface: string,
|
||||
provider: string,
|
||||
discordFallback?: Array<string | number>,
|
||||
): Array<string | number> | undefined {
|
||||
switch (surface) {
|
||||
switch (provider) {
|
||||
case "whatsapp":
|
||||
return allowFrom?.whatsapp;
|
||||
case "telegram":
|
||||
@@ -134,14 +138,14 @@ function resolveElevatedAllowList(
|
||||
}
|
||||
|
||||
function isApprovedElevatedSender(params: {
|
||||
surface: string;
|
||||
provider: string;
|
||||
ctx: MsgContext;
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
discordFallback?: Array<string | number>;
|
||||
}): boolean {
|
||||
const rawAllow = resolveElevatedAllowList(
|
||||
params.allowFrom,
|
||||
params.surface,
|
||||
params.provider,
|
||||
params.discordFallback,
|
||||
);
|
||||
if (!rawAllow || rawAllow.length === 0) return false;
|
||||
@@ -215,14 +219,16 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
|
||||
const workspaceDirRaw =
|
||||
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDirRaw,
|
||||
ensureBootstrapFiles: true,
|
||||
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
const timeoutSeconds = Math.max(agentCfg?.timeoutSeconds ?? 600, 1);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
const timeoutMs = resolveAgentTimeoutMs({ cfg });
|
||||
const configuredTypingSeconds =
|
||||
agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds;
|
||||
const typingIntervalSeconds =
|
||||
@@ -233,6 +239,7 @@ export async function getReplyFromConfig(
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
log: defaultRuntime.log,
|
||||
});
|
||||
opts?.onTypingController?.(typing);
|
||||
|
||||
let transcribedText: string | undefined;
|
||||
if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) {
|
||||
@@ -246,7 +253,7 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
|
||||
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
||||
const commandAuth = resolveCommandAuthorization({
|
||||
resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized,
|
||||
@@ -273,7 +280,47 @@ export async function getReplyFromConfig(
|
||||
} = sessionState;
|
||||
|
||||
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const parsedDirectives = parseInlineDirectives(rawBody);
|
||||
const clearInlineDirectives = (cleaned: string): InlineDirectives => ({
|
||||
cleaned,
|
||||
hasThinkDirective: false,
|
||||
thinkLevel: undefined,
|
||||
rawThinkLevel: undefined,
|
||||
hasVerboseDirective: false,
|
||||
verboseLevel: undefined,
|
||||
rawVerboseLevel: undefined,
|
||||
hasElevatedDirective: false,
|
||||
elevatedLevel: undefined,
|
||||
rawElevatedLevel: undefined,
|
||||
hasStatusDirective: false,
|
||||
hasModelDirective: false,
|
||||
rawModelDirective: undefined,
|
||||
hasQueueDirective: false,
|
||||
queueMode: undefined,
|
||||
queueReset: false,
|
||||
rawQueueMode: undefined,
|
||||
debounceMs: undefined,
|
||||
cap: undefined,
|
||||
dropPolicy: undefined,
|
||||
rawDebounce: undefined,
|
||||
rawCap: undefined,
|
||||
rawDrop: undefined,
|
||||
hasQueueOptions: false,
|
||||
});
|
||||
let parsedDirectives = parseInlineDirectives(rawBody);
|
||||
const hasDirective =
|
||||
parsedDirectives.hasThinkDirective ||
|
||||
parsedDirectives.hasVerboseDirective ||
|
||||
parsedDirectives.hasElevatedDirective ||
|
||||
parsedDirectives.hasStatusDirective ||
|
||||
parsedDirectives.hasModelDirective ||
|
||||
parsedDirectives.hasQueueDirective;
|
||||
if (hasDirective) {
|
||||
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
|
||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
||||
if (noMentions.trim().length > 0) {
|
||||
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
|
||||
}
|
||||
}
|
||||
const directives = commandAuthorized
|
||||
? parsedDirectives
|
||||
: {
|
||||
@@ -288,20 +335,20 @@ export async function getReplyFromConfig(
|
||||
sessionCtx.Body = parsedDirectives.cleaned;
|
||||
sessionCtx.BodyStripped = parsedDirectives.cleaned;
|
||||
|
||||
const surfaceKey =
|
||||
sessionCtx.Surface?.trim().toLowerCase() ??
|
||||
ctx.Surface?.trim().toLowerCase() ??
|
||||
const messageProviderKey =
|
||||
sessionCtx.Provider?.trim().toLowerCase() ??
|
||||
ctx.Provider?.trim().toLowerCase() ??
|
||||
"";
|
||||
const elevatedConfig = agentCfg?.elevated;
|
||||
const discordElevatedFallback =
|
||||
surfaceKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
|
||||
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
|
||||
const elevatedEnabled = elevatedConfig?.enabled !== false;
|
||||
const elevatedAllowed =
|
||||
elevatedEnabled &&
|
||||
Boolean(
|
||||
surfaceKey &&
|
||||
messageProviderKey &&
|
||||
isApprovedElevatedSender({
|
||||
surface: surfaceKey,
|
||||
provider: messageProviderKey,
|
||||
ctx,
|
||||
allowFrom: elevatedConfig?.allowFrom,
|
||||
discordFallback: discordElevatedFallback,
|
||||
@@ -344,7 +391,7 @@ export async function getReplyFromConfig(
|
||||
: "text_end";
|
||||
const blockStreamingEnabled = resolvedBlockStreaming === "on";
|
||||
const blockReplyChunking = blockStreamingEnabled
|
||||
? resolveBlockStreamingChunking(cfg, sessionCtx.Surface)
|
||||
? resolveBlockStreamingChunking(cfg, sessionCtx.Provider)
|
||||
: undefined;
|
||||
|
||||
const modelState = await createModelSelectionState({
|
||||
@@ -460,9 +507,14 @@ export async function getReplyFromConfig(
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
});
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: command.surface,
|
||||
commandSource: ctx.CommandSource,
|
||||
});
|
||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||
if (
|
||||
command.isWhatsAppSurface &&
|
||||
command.isWhatsAppProvider &&
|
||||
isEmptyConfig &&
|
||||
command.from &&
|
||||
command.to &&
|
||||
@@ -530,20 +582,15 @@ export async function getReplyFromConfig(
|
||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const rawBodyTrimmed = (ctx.Body ?? "").trim();
|
||||
const baseBodyTrimmedRaw = baseBody.trim();
|
||||
const strippedCommandBody = isGroup
|
||||
? stripMentions(triggerBodyNormalized, ctx, cfg)
|
||||
: triggerBodyNormalized;
|
||||
if (
|
||||
!commandAuth.isAuthorizedSender &&
|
||||
CONTROL_COMMAND_PREFIX_RE.test(strippedCommandBody.trim())
|
||||
allowTextCommands &&
|
||||
!commandAuthorized &&
|
||||
!baseBodyTrimmedRaw &&
|
||||
hasControlCommand(rawBody)
|
||||
) {
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
}
|
||||
if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) {
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
}
|
||||
const isBareSessionReset =
|
||||
isNewSession &&
|
||||
baseBodyTrimmedRaw.length === 0 &&
|
||||
@@ -637,7 +684,7 @@ export async function getReplyFromConfig(
|
||||
: queueBodyBase;
|
||||
const resolvedQueue = resolveQueueSettings({
|
||||
cfg,
|
||||
surface: sessionCtx.Surface,
|
||||
provider: sessionCtx.Provider,
|
||||
sessionEntry,
|
||||
inlineMode: perMessageQueueMode,
|
||||
inlineOptions: perMessageQueueOptions,
|
||||
@@ -668,9 +715,11 @@ export async function getReplyFromConfig(
|
||||
summaryLine: baseBodyTrimmedRaw,
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
agentId,
|
||||
agentDir,
|
||||
sessionId: sessionIdFinal,
|
||||
sessionKey,
|
||||
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
|
||||
messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
|
||||
@@ -51,6 +51,8 @@ function createTyping(): TypingController {
|
||||
startTypingLoop: vi.fn(async () => {}),
|
||||
startTypingOnText: vi.fn(async () => {}),
|
||||
refreshTypingTtl: vi.fn(),
|
||||
markRunComplete: vi.fn(),
|
||||
markDispatchIdle: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -70,7 +72,7 @@ function createMinimalRun(params?: {
|
||||
const typing = createTyping();
|
||||
const opts = params?.opts;
|
||||
const sessionCtx = {
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
@@ -82,7 +84,7 @@ function createMinimalRun(params?: {
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey,
|
||||
surface: "whatsapp",
|
||||
messageProvider: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
@@ -208,7 +210,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
expect(payloads[0]?.text).toContain("count 1");
|
||||
expect(sessionStore.main.compactionCount).toBe(1);
|
||||
});
|
||||
|
||||
it("resets corrupted Gemini sessions and deletes transcripts", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(
|
||||
@@ -354,4 +355,26 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites Bun socket errors into friendly text", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
|
||||
payloads: [
|
||||
{
|
||||
text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
meta: {},
|
||||
}));
|
||||
|
||||
const { run } = createMinimalRun();
|
||||
const res = await run();
|
||||
const payloads = Array.isArray(res) ? res : res ? [res] : [];
|
||||
expect(payloads.length).toBe(1);
|
||||
expect(payloads[0]?.text).toContain("LLM connection failed");
|
||||
expect(payloads[0]?.text).toContain(
|
||||
"socket connection was closed unexpectedly",
|
||||
);
|
||||
expect(payloads[0]?.text).toContain("```");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
queueEmbeddedPiMessage,
|
||||
runEmbeddedPiAgent,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionTranscriptPath,
|
||||
@@ -32,6 +33,21 @@ import { extractReplyToTag } from "./reply-tags.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
|
||||
|
||||
const isBunFetchSocketError = (message?: string) =>
|
||||
Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message));
|
||||
|
||||
const formatBunFetchSocketError = (message: string) => {
|
||||
const trimmed = message.trim();
|
||||
return [
|
||||
"⚠️ LLM connection failed. This could be due to server issues, network problems, or context length exceeded (e.g., with local LLMs like LM Studio). Original error:",
|
||||
"```",
|
||||
trimmed || "Unknown error",
|
||||
"```",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
export async function runReplyAgent(params: {
|
||||
commandBody: string;
|
||||
followupRun: FollowupRun;
|
||||
@@ -107,6 +123,7 @@ export async function runReplyAgent(params: {
|
||||
const streamedPayloadKeys = new Set<string>();
|
||||
const pendingStreamedPayloadKeys = new Set<string>();
|
||||
const pendingBlockTasks = new Set<Promise<void>>();
|
||||
const pendingToolTasks = new Set<Promise<void>>();
|
||||
let didStreamBlockReply = false;
|
||||
const buildPayloadKey = (payload: ReplyPayload) => {
|
||||
const text = payload.text?.trim() ?? "";
|
||||
@@ -188,9 +205,11 @@ export async function runReplyAgent(params: {
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey,
|
||||
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
|
||||
messageProvider:
|
||||
sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
sessionFile: followupRun.run.sessionFile,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
agentDir: followupRun.run.agentDir,
|
||||
config: followupRun.run.config,
|
||||
skillsSnapshot: followupRun.run.skillsSnapshot,
|
||||
prompt: commandBody,
|
||||
@@ -239,7 +258,8 @@ export async function runReplyAgent(params: {
|
||||
: undefined,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase = String(evt.data.phase ?? "");
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
@@ -310,33 +330,45 @@ export async function runReplyAgent(params: {
|
||||
: undefined,
|
||||
shouldEmitToolResult,
|
||||
onToolResult: opts?.onToolResult
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "message",
|
||||
? (payload) => {
|
||||
// `subscribeEmbeddedPiSession` may invoke tool callbacks without awaiting them.
|
||||
// If a tool callback starts typing after the run finalized, we can end up with
|
||||
// a typing loop that never sees a matching markRunComplete(). Track and drain.
|
||||
const task = (async () => {
|
||||
let text = payload.text;
|
||||
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose(
|
||||
"Stripped stray HEARTBEAT_OK token from reply",
|
||||
);
|
||||
}
|
||||
if (
|
||||
stripped.shouldSkip &&
|
||||
(payload.mediaUrls?.length ?? 0) === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
if (!isHeartbeat) {
|
||||
await typing.startTypingOnText(text);
|
||||
}
|
||||
await opts.onToolResult?.({
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose(
|
||||
"Stripped stray HEARTBEAT_OK token from reply",
|
||||
);
|
||||
}
|
||||
if (
|
||||
stripped.shouldSkip &&
|
||||
(payload.mediaUrls?.length ?? 0) === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
if (!isHeartbeat) {
|
||||
await typing.startTypingOnText(text);
|
||||
}
|
||||
await opts.onToolResult?.({
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
})()
|
||||
.catch((err) => {
|
||||
logVerbose(`tool result delivery failed: ${String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingToolTasks.delete(task);
|
||||
});
|
||||
pendingToolTasks.add(task);
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
@@ -408,16 +440,28 @@ export async function runReplyAgent(params: {
|
||||
}
|
||||
|
||||
const payloadArray = runResult.payloads ?? [];
|
||||
if (payloadArray.length === 0) return finalizeWithFollowup(undefined);
|
||||
if (pendingBlockTasks.size > 0) {
|
||||
await Promise.allSettled(pendingBlockTasks);
|
||||
}
|
||||
if (pendingToolTasks.size > 0) {
|
||||
await Promise.allSettled(pendingToolTasks);
|
||||
}
|
||||
// Drain any late tool/block deliveries before deciding there's "nothing to send".
|
||||
// Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and
|
||||
// keep the typing indicator stuck.
|
||||
if (payloadArray.length === 0) return finalizeWithFollowup(undefined);
|
||||
|
||||
const sanitizedPayloads = isHeartbeat
|
||||
? payloadArray
|
||||
: payloadArray.flatMap((payload) => {
|
||||
const text = payload.text;
|
||||
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
||||
let text = payload.text;
|
||||
|
||||
if (payload.isError && text && isBunFetchSocketError(text)) {
|
||||
text = formatBunFetchSocketError(text);
|
||||
}
|
||||
|
||||
if (!text || !text.includes("HEARTBEAT_OK"))
|
||||
return [{ ...payload, text }];
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
@@ -485,7 +529,7 @@ export async function runReplyAgent(params: {
|
||||
sessionEntry?.contextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
if (usage) {
|
||||
if (hasNonzeroUsage(usage)) {
|
||||
const entry = sessionEntry ?? sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
const input = usage.input ?? 0;
|
||||
@@ -552,6 +596,6 @@ export async function runReplyAgent(params: {
|
||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||
);
|
||||
} finally {
|
||||
typing.cleanup();
|
||||
typing.markRunComplete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveTextChunkLimit, type TextChunkSurface } from "../chunk.js";
|
||||
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||
|
||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||
|
||||
const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([
|
||||
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
@@ -14,24 +14,26 @@ const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([
|
||||
"webchat",
|
||||
]);
|
||||
|
||||
function normalizeChunkSurface(surface?: string): TextChunkSurface | undefined {
|
||||
if (!surface) return undefined;
|
||||
const cleaned = surface.trim().toLowerCase();
|
||||
return BLOCK_CHUNK_SURFACES.has(cleaned as TextChunkSurface)
|
||||
? (cleaned as TextChunkSurface)
|
||||
function normalizeChunkProvider(
|
||||
provider?: string,
|
||||
): TextChunkProvider | undefined {
|
||||
if (!provider) return undefined;
|
||||
const cleaned = provider.trim().toLowerCase();
|
||||
return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider)
|
||||
? (cleaned as TextChunkProvider)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveBlockStreamingChunking(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
surface?: string,
|
||||
provider?: string,
|
||||
): {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
} {
|
||||
const surfaceKey = normalizeChunkSurface(surface);
|
||||
const textLimit = resolveTextChunkLimit(cfg, surfaceKey);
|
||||
const providerKey = normalizeChunkProvider(provider);
|
||||
const textLimit = resolveTextChunkLimit(cfg, providerKey);
|
||||
const chunkCfg = cfg?.agent?.blockStreamingChunk;
|
||||
const maxRequested = Math.max(
|
||||
1,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { normalizeE164 } from "../../utils.js";
|
||||
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
|
||||
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import { shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import {
|
||||
normalizeGroupActivation,
|
||||
parseActivationCommand,
|
||||
@@ -48,7 +49,8 @@ import { incrementCompactionCount } from "./session-updates.js";
|
||||
|
||||
export type CommandContext = {
|
||||
surface: string;
|
||||
isWhatsAppSurface: boolean;
|
||||
provider: string;
|
||||
isWhatsAppProvider: boolean;
|
||||
ownerList: string[];
|
||||
isAuthorizedSender: boolean;
|
||||
senderE164?: string;
|
||||
@@ -102,11 +104,7 @@ function extractCompactInstructions(params: {
|
||||
const trimmed = stripped.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const prefix = lowered.startsWith("/compact")
|
||||
? "/compact"
|
||||
: lowered.startsWith("compact")
|
||||
? "compact"
|
||||
: null;
|
||||
const prefix = lowered.startsWith("/compact") ? "/compact" : null;
|
||||
if (!prefix) return undefined;
|
||||
let rest = trimmed.slice(prefix.length).trimStart();
|
||||
if (rest.startsWith(":")) rest = rest.slice(1).trimStart();
|
||||
@@ -127,7 +125,8 @@ export function buildCommandContext(params: {
|
||||
cfg,
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
});
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase();
|
||||
const provider = (ctx.Provider ?? surface).trim().toLowerCase();
|
||||
const abortKey =
|
||||
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
@@ -137,7 +136,8 @@ export function buildCommandContext(params: {
|
||||
|
||||
return {
|
||||
surface,
|
||||
isWhatsAppSurface: auth.isWhatsAppSurface,
|
||||
provider,
|
||||
isWhatsAppProvider: auth.isWhatsAppProvider,
|
||||
ownerList: auth.ownerList,
|
||||
isAuthorizedSender: auth.isAuthorizedSender,
|
||||
senderE164: auth.senderE164,
|
||||
@@ -197,9 +197,7 @@ export async function handleCommands(params: {
|
||||
|
||||
const resetRequested =
|
||||
command.commandBodyNormalized === "/reset" ||
|
||||
command.commandBodyNormalized === "reset" ||
|
||||
command.commandBodyNormalized === "/new" ||
|
||||
command.commandBodyNormalized === "new";
|
||||
command.commandBodyNormalized === "/new";
|
||||
if (resetRequested && !command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /reset from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
@@ -213,8 +211,13 @@ export async function handleCommands(params: {
|
||||
const sendPolicyCommand = parseSendPolicyCommand(
|
||||
command.commandBodyNormalized,
|
||||
);
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: command.surface,
|
||||
commandSource: ctx.CommandSource,
|
||||
});
|
||||
|
||||
if (activationCommand.hasCommand) {
|
||||
if (allowTextCommands && activationCommand.hasCommand) {
|
||||
if (!isGroup) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
@@ -226,14 +229,14 @@ export async function handleCommands(params: {
|
||||
? normalizeE164(command.senderE164)
|
||||
: "";
|
||||
const isActivationOwner =
|
||||
!command.isWhatsAppSurface || activationOwnerList.length === 0
|
||||
!command.isWhatsAppProvider || activationOwnerList.length === 0
|
||||
? command.isAuthorizedSender
|
||||
: Boolean(activationSenderE164) &&
|
||||
activationOwnerList.includes(activationSenderE164);
|
||||
|
||||
if (
|
||||
!command.isAuthorizedSender ||
|
||||
(command.isWhatsAppSurface && !isActivationOwner)
|
||||
(command.isWhatsAppProvider && !isActivationOwner)
|
||||
) {
|
||||
logVerbose(
|
||||
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
|
||||
@@ -261,7 +264,7 @@ export async function handleCommands(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (sendPolicyCommand.hasCommand) {
|
||||
if (allowTextCommands && sendPolicyCommand.hasCommand) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
@@ -298,11 +301,7 @@ export async function handleCommands(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
command.commandBodyNormalized === "/restart" ||
|
||||
command.commandBodyNormalized === "restart" ||
|
||||
command.commandBodyNormalized.startsWith("/restart ")
|
||||
) {
|
||||
if (allowTextCommands && command.commandBodyNormalized === "/restart") {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
@@ -318,11 +317,8 @@ export async function handleCommands(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const helpRequested =
|
||||
command.commandBodyNormalized === "/help" ||
|
||||
command.commandBodyNormalized === "help" ||
|
||||
/(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized);
|
||||
if (helpRequested) {
|
||||
const helpRequested = command.commandBodyNormalized === "/help";
|
||||
if (allowTextCommands && helpRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /help from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
@@ -334,10 +330,8 @@ export async function handleCommands(params: {
|
||||
|
||||
const statusRequested =
|
||||
directives.hasStatusDirective ||
|
||||
command.commandBodyNormalized === "/status" ||
|
||||
command.commandBodyNormalized === "status" ||
|
||||
command.commandBodyNormalized.startsWith("/status ");
|
||||
if (statusRequested) {
|
||||
command.commandBodyNormalized === "/status";
|
||||
if (allowTextCommands && statusRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
@@ -383,9 +377,7 @@ export async function handleCommands(params: {
|
||||
|
||||
const compactRequested =
|
||||
command.commandBodyNormalized === "/compact" ||
|
||||
command.commandBodyNormalized === "compact" ||
|
||||
command.commandBodyNormalized.startsWith("/compact ") ||
|
||||
command.commandBodyNormalized.startsWith("compact ");
|
||||
command.commandBodyNormalized.startsWith("/compact ");
|
||||
if (compactRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
@@ -413,7 +405,7 @@ export async function handleCommands(params: {
|
||||
const result = await compactEmbeddedPiSession({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
surface: command.surface,
|
||||
messageProvider: command.provider,
|
||||
sessionFile: resolveSessionTranscriptPath(sessionId),
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
@@ -462,7 +454,7 @@ export async function handleCommands(params: {
|
||||
}
|
||||
|
||||
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
|
||||
if (abortRequested) {
|
||||
if (allowTextCommands && abortRequested) {
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
sessionEntry.abortedLastRun = true;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
@@ -480,7 +472,7 @@ export async function handleCommands(params: {
|
||||
cfg,
|
||||
entry: sessionEntry,
|
||||
sessionKey,
|
||||
surface: sessionEntry?.surface ?? command.surface,
|
||||
provider: sessionEntry?.provider ?? command.provider,
|
||||
chatType: sessionEntry?.chatType,
|
||||
});
|
||||
if (sendPolicy === "deny") {
|
||||
|
||||
46
src/auto-reply/reply/dispatch-from-config.ts
Normal file
46
src/auto-reply/reply/dispatch-from-config.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { getReplyFromConfig } from "../reply.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||
|
||||
type DispatchFromConfigResult = {
|
||||
queuedFinal: boolean;
|
||||
counts: Record<ReplyDispatchKind, number>;
|
||||
};
|
||||
|
||||
export async function dispatchReplyFromConfig(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
dispatcher: ReplyDispatcher;
|
||||
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
|
||||
replyResolver?: typeof getReplyFromConfig;
|
||||
}): Promise<DispatchFromConfigResult> {
|
||||
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
||||
params.ctx,
|
||||
{
|
||||
...params.replyOptions,
|
||||
onToolResult: (payload: ReplyPayload) => {
|
||||
params.dispatcher.sendToolResult(payload);
|
||||
},
|
||||
onBlockReply: (payload: ReplyPayload) => {
|
||||
params.dispatcher.sendBlockReply(payload);
|
||||
},
|
||||
},
|
||||
params.cfg,
|
||||
);
|
||||
|
||||
const replies = replyResult
|
||||
? Array.isArray(replyResult)
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
|
||||
let queuedFinal = false;
|
||||
for (const reply of replies) {
|
||||
queuedFinal = params.dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||
}
|
||||
await params.dispatcher.waitForIdle();
|
||||
|
||||
return { queuedFinal, counts: params.dispatcher.getQueuedCounts() };
|
||||
}
|
||||
@@ -37,6 +37,8 @@ function createTyping(): TypingController {
|
||||
startTypingLoop: vi.fn(async () => {}),
|
||||
startTypingOnText: vi.fn(async () => {}),
|
||||
refreshTypingTtl: vi.fn(),
|
||||
markRunComplete: vi.fn(),
|
||||
markDispatchIdle: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -88,7 +90,7 @@ describe("createFollowupRunner compaction", () => {
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
surface: "whatsapp",
|
||||
messageProvider: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
@@ -58,153 +59,160 @@ export function createFollowupRunner(params: {
|
||||
};
|
||||
|
||||
return async (queued: FollowupRun) => {
|
||||
const runId = crypto.randomUUID();
|
||||
if (queued.run.sessionKey) {
|
||||
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
|
||||
}
|
||||
let autoCompactionCompleted = false;
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = queued.run.provider;
|
||||
let fallbackModel = queued.run.model;
|
||||
try {
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: queued.run.config,
|
||||
provider: queued.run.provider,
|
||||
model: queued.run.model,
|
||||
run: (provider, model) =>
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: queued.run.sessionId,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
surface: queued.run.surface,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
skillsSnapshot: queued.run.skillsSnapshot,
|
||||
prompt: queued.prompt,
|
||||
extraSystemPrompt: queued.run.extraSystemPrompt,
|
||||
ownerNumbers: queued.run.ownerNumbers,
|
||||
enforceFinalTag: queued.run.enforceFinalTag,
|
||||
provider,
|
||||
model,
|
||||
authProfileId: queued.run.authProfileId,
|
||||
thinkLevel: queued.run.thinkLevel,
|
||||
verboseLevel: queued.run.verboseLevel,
|
||||
bashElevated: queued.run.bashElevated,
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase = String(evt.data.phase ?? "");
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadArray = runResult.payloads ?? [];
|
||||
if (payloadArray.length === 0) return;
|
||||
const sanitizedPayloads = payloadArray.flatMap((payload) => {
|
||||
const text = payload.text;
|
||||
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
||||
.map((payload) => {
|
||||
const { cleaned, replyToId } = extractReplyToTag(payload.text);
|
||||
return {
|
||||
...payload,
|
||||
text: cleaned ? cleaned : undefined,
|
||||
replyToId: replyToId ?? payload.replyToId,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(payload) =>
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||
);
|
||||
|
||||
if (replyTaggedPayloads.length === 0) return;
|
||||
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementCompactionCount({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
if (queued.run.verboseLevel === "on") {
|
||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||
replyTaggedPayloads.unshift({
|
||||
text: `🧹 Auto-compaction complete${suffix}.`,
|
||||
const runId = crypto.randomUUID();
|
||||
if (queued.run.sessionKey) {
|
||||
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
|
||||
}
|
||||
let autoCompactionCompleted = false;
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = queued.run.provider;
|
||||
let fallbackModel = queued.run.model;
|
||||
try {
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: queued.run.config,
|
||||
provider: queued.run.provider,
|
||||
model: queued.run.model,
|
||||
run: (provider, model) =>
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: queued.run.sessionId,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
messageProvider: queued.run.messageProvider,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
skillsSnapshot: queued.run.skillsSnapshot,
|
||||
prompt: queued.prompt,
|
||||
extraSystemPrompt: queued.run.extraSystemPrompt,
|
||||
ownerNumbers: queued.run.ownerNumbers,
|
||||
enforceFinalTag: queued.run.enforceFinalTag,
|
||||
provider,
|
||||
model,
|
||||
authProfileId: queued.run.authProfileId,
|
||||
thinkLevel: queued.run.thinkLevel,
|
||||
verboseLevel: queued.run.verboseLevel,
|
||||
bashElevated: queued.run.bashElevated,
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase =
|
||||
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error?.(
|
||||
`Followup agent failed before reply: ${message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
sessionEntry?.contextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
const payloadArray = runResult.payloads ?? [];
|
||||
if (payloadArray.length === 0) return;
|
||||
const sanitizedPayloads = payloadArray.flatMap((payload) => {
|
||||
const text = payload.text;
|
||||
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
|
||||
if (usage) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
||||
.map((payload) => {
|
||||
const { cleaned, replyToId } = extractReplyToTag(payload.text);
|
||||
return {
|
||||
...payload,
|
||||
text: cleaned ? cleaned : undefined,
|
||||
replyToId: replyToId ?? payload.replyToId,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(payload) =>
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||
);
|
||||
|
||||
if (replyTaggedPayloads.length === 0) return;
|
||||
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementCompactionCount({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
if (queued.run.verboseLevel === "on") {
|
||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||
replyTaggedPayloads.unshift({
|
||||
text: `🧹 Auto-compaction complete${suffix}.`,
|
||||
});
|
||||
}
|
||||
} else if (modelUsed || contextTokensUsed) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
sessionEntry?.contextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
if (hasNonzeroUsage(usage)) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
} else if (modelUsed || contextTokensUsed) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await sendFollowupPayloads(replyTaggedPayloads);
|
||||
await sendFollowupPayloads(replyTaggedPayloads);
|
||||
} finally {
|
||||
typing.markRunComplete();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ describe("resolveGroupRequireMention", () => {
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Surface: "discord",
|
||||
Provider: "discord",
|
||||
From: "group:123",
|
||||
GroupRoom: "#general",
|
||||
GroupSpace: "145",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
surface: "discord",
|
||||
provider: "discord",
|
||||
id: "123",
|
||||
chatType: "group",
|
||||
};
|
||||
@@ -44,12 +44,12 @@ describe("resolveGroupRequireMention", () => {
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Surface: "slack",
|
||||
Provider: "slack",
|
||||
From: "slack:channel:C123",
|
||||
GroupSubject: "#general",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
surface: "slack",
|
||||
provider: "slack",
|
||||
id: "C123",
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveProviderGroupRequireMention } from "../../config/group-policy.js";
|
||||
import type {
|
||||
GroupKeyResolution,
|
||||
SessionEntry,
|
||||
@@ -49,44 +50,23 @@ export function resolveGroupRequireMention(params: {
|
||||
groupResolution?: GroupKeyResolution;
|
||||
}): boolean {
|
||||
const { cfg, ctx, groupResolution } = params;
|
||||
const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase();
|
||||
const provider =
|
||||
groupResolution?.provider ?? ctx.Provider?.trim().toLowerCase();
|
||||
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
|
||||
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
|
||||
const groupSpace = ctx.GroupSpace?.trim();
|
||||
if (surface === "telegram") {
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.telegram?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
if (
|
||||
provider === "telegram" ||
|
||||
provider === "whatsapp" ||
|
||||
provider === "imessage"
|
||||
) {
|
||||
return resolveProviderGroupRequireMention({
|
||||
cfg,
|
||||
provider,
|
||||
groupId,
|
||||
});
|
||||
}
|
||||
if (surface === "whatsapp") {
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.whatsapp?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
}
|
||||
if (surface === "imessage") {
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.imessage?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
}
|
||||
if (surface === "discord") {
|
||||
if (provider === "discord") {
|
||||
const guildEntry = resolveDiscordGuildEntry(
|
||||
cfg.discord?.guilds,
|
||||
groupSpace,
|
||||
@@ -111,7 +91,7 @@ export function resolveGroupRequireMention(params: {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (surface === "slack") {
|
||||
if (provider === "slack") {
|
||||
const channels = cfg.slack?.channels ?? {};
|
||||
const keys = Object.keys(channels);
|
||||
if (keys.length === 0) return true;
|
||||
@@ -158,18 +138,18 @@ export function buildGroupIntro(params: {
|
||||
params.defaultActivation;
|
||||
const subject = params.sessionCtx.GroupSubject?.trim();
|
||||
const members = params.sessionCtx.GroupMembers?.trim();
|
||||
const surface = params.sessionCtx.Surface?.trim().toLowerCase();
|
||||
const surfaceLabel = (() => {
|
||||
if (!surface) return "chat";
|
||||
if (surface === "whatsapp") return "WhatsApp";
|
||||
if (surface === "telegram") return "Telegram";
|
||||
if (surface === "discord") return "Discord";
|
||||
if (surface === "webchat") return "WebChat";
|
||||
return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`;
|
||||
const provider = params.sessionCtx.Provider?.trim().toLowerCase();
|
||||
const providerLabel = (() => {
|
||||
if (!provider) return "chat";
|
||||
if (provider === "whatsapp") return "WhatsApp";
|
||||
if (provider === "telegram") return "Telegram";
|
||||
if (provider === "discord") return "Discord";
|
||||
if (provider === "webchat") return "WebChat";
|
||||
return `${provider.at(0)?.toUpperCase() ?? ""}${provider.slice(1)}`;
|
||||
})();
|
||||
const subjectLine = subject
|
||||
? `You are replying inside the ${surfaceLabel} group "${subject}".`
|
||||
: `You are replying inside a ${surfaceLabel} group chat.`;
|
||||
? `You are replying inside the ${providerLabel} group "${subject}".`
|
||||
: `You are replying inside a ${providerLabel} group chat.`;
|
||||
const membersLine = members ? `Group members: ${members}.` : undefined;
|
||||
const activationLine =
|
||||
activation === "always"
|
||||
|
||||
@@ -23,9 +23,11 @@ export type FollowupRun = {
|
||||
summaryLine?: string;
|
||||
enqueuedAt: number;
|
||||
run: {
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
surface?: string;
|
||||
messageProvider?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config: ClawdbotConfig;
|
||||
@@ -425,8 +427,8 @@ export function scheduleFollowupDrain(
|
||||
}
|
||||
})();
|
||||
}
|
||||
function defaultQueueModeForSurface(surface?: string): QueueMode {
|
||||
const normalized = surface?.trim().toLowerCase();
|
||||
function defaultQueueModeForProvider(provider?: string): QueueMode {
|
||||
const normalized = provider?.trim().toLowerCase();
|
||||
if (normalized === "discord") return "collect";
|
||||
if (normalized === "webchat") return "collect";
|
||||
if (normalized === "whatsapp") return "collect";
|
||||
@@ -437,23 +439,23 @@ function defaultQueueModeForSurface(surface?: string): QueueMode {
|
||||
}
|
||||
export function resolveQueueSettings(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
surface?: string;
|
||||
provider?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
inlineMode?: QueueMode;
|
||||
inlineOptions?: Partial<QueueSettings>;
|
||||
}): QueueSettings {
|
||||
const surfaceKey = params.surface?.trim().toLowerCase();
|
||||
const providerKey = params.provider?.trim().toLowerCase();
|
||||
const queueCfg = params.cfg.routing?.queue;
|
||||
const surfaceModeRaw =
|
||||
surfaceKey && queueCfg?.bySurface
|
||||
? (queueCfg.bySurface as Record<string, string | undefined>)[surfaceKey]
|
||||
const providerModeRaw =
|
||||
providerKey && queueCfg?.byProvider
|
||||
? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]
|
||||
: undefined;
|
||||
const resolvedMode =
|
||||
params.inlineMode ??
|
||||
normalizeQueueMode(params.sessionEntry?.queueMode) ??
|
||||
normalizeQueueMode(surfaceModeRaw) ??
|
||||
normalizeQueueMode(providerModeRaw) ??
|
||||
normalizeQueueMode(queueCfg?.mode) ??
|
||||
defaultQueueModeForSurface(surfaceKey);
|
||||
defaultQueueModeForProvider(providerKey);
|
||||
const debounceRaw =
|
||||
params.inlineOptions?.debounceMs ??
|
||||
params.sessionEntry?.queueDebounceMs ??
|
||||
|
||||
@@ -79,4 +79,18 @@ describe("createReplyDispatcher", () => {
|
||||
await dispatcher.waitForIdle();
|
||||
expect(delivered).toEqual(["tool", "block", "final"]);
|
||||
});
|
||||
|
||||
it("fires onIdle when the queue drains", async () => {
|
||||
const deliver = vi.fn(
|
||||
async () => await new Promise((resolve) => setTimeout(resolve, 5)),
|
||||
);
|
||||
const onIdle = vi.fn();
|
||||
const dispatcher = createReplyDispatcher({ deliver, onIdle });
|
||||
|
||||
dispatcher.sendToolResult({ text: "one" });
|
||||
dispatcher.sendFinalReply({ text: "two" });
|
||||
|
||||
await dispatcher.waitForIdle();
|
||||
expect(onIdle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export type ReplyDispatchKind = "tool" | "block" | "final";
|
||||
|
||||
@@ -18,10 +19,25 @@ export type ReplyDispatcherOptions = {
|
||||
deliver: ReplyDispatchDeliverer;
|
||||
responsePrefix?: string;
|
||||
onHeartbeatStrip?: () => void;
|
||||
onIdle?: () => void;
|
||||
onError?: ReplyDispatchErrorHandler;
|
||||
};
|
||||
|
||||
type ReplyDispatcher = {
|
||||
type ReplyDispatcherWithTypingOptions = Omit<
|
||||
ReplyDispatcherOptions,
|
||||
"onIdle"
|
||||
> & {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
onIdle?: () => void;
|
||||
};
|
||||
|
||||
type ReplyDispatcherWithTypingResult = {
|
||||
dispatcher: ReplyDispatcher;
|
||||
replyOptions: Pick<GetReplyOptions, "onReplyStart" | "onTypingController">;
|
||||
markDispatchIdle: () => void;
|
||||
};
|
||||
|
||||
export type ReplyDispatcher = {
|
||||
sendToolResult: (payload: ReplyPayload) => boolean;
|
||||
sendBlockReply: (payload: ReplyPayload) => boolean;
|
||||
sendFinalReply: (payload: ReplyPayload) => boolean;
|
||||
@@ -70,6 +86,8 @@ export function createReplyDispatcher(
|
||||
options: ReplyDispatcherOptions,
|
||||
): ReplyDispatcher {
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
// Track in-flight deliveries so we can emit a reliable "idle" signal.
|
||||
let pending = 0;
|
||||
// Serialize outbound replies to preserve tool/block/final order.
|
||||
const queuedCounts: Record<ReplyDispatchKind, number> = {
|
||||
tool: 0,
|
||||
@@ -81,10 +99,17 @@ export function createReplyDispatcher(
|
||||
const normalized = normalizeReplyPayload(payload, options);
|
||||
if (!normalized) return false;
|
||||
queuedCounts[kind] += 1;
|
||||
pending += 1;
|
||||
sendChain = sendChain
|
||||
.then(() => options.deliver(normalized, { kind }))
|
||||
.catch((err) => {
|
||||
options.onError?.(err, { kind });
|
||||
})
|
||||
.finally(() => {
|
||||
pending -= 1;
|
||||
if (pending === 0) {
|
||||
options.onIdle?.();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
};
|
||||
@@ -97,3 +122,31 @@ export function createReplyDispatcher(
|
||||
getQueuedCounts: () => ({ ...queuedCounts }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createReplyDispatcherWithTyping(
|
||||
options: ReplyDispatcherWithTypingOptions,
|
||||
): ReplyDispatcherWithTypingResult {
|
||||
const { onReplyStart, onIdle, ...dispatcherOptions } = options;
|
||||
let typingController: TypingController | undefined;
|
||||
const dispatcher = createReplyDispatcher({
|
||||
...dispatcherOptions,
|
||||
onIdle: () => {
|
||||
typingController?.markDispatchIdle();
|
||||
onIdle?.();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
onReplyStart,
|
||||
onTypingController: (typing) => {
|
||||
typingController = typing;
|
||||
},
|
||||
},
|
||||
markDispatchIdle: () => {
|
||||
typingController?.markDispatchIdle();
|
||||
onIdle?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DEFAULT_RESET_TRIGGERS,
|
||||
type GroupKeyResolution,
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveGroupSessionKey,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
@@ -43,6 +44,7 @@ export async function initSessionState(params: {
|
||||
const { ctx, cfg, commandAuthorized } = params;
|
||||
const sessionCfg = cfg.session;
|
||||
const mainKey = sessionCfg?.mainKey ?? "main";
|
||||
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: DEFAULT_RESET_TRIGGERS;
|
||||
@@ -51,12 +53,12 @@ export async function initSessionState(params: {
|
||||
1,
|
||||
);
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||
|
||||
const sessionStore: Record<string, SessionEntry> =
|
||||
loadSessionStore(storePath);
|
||||
let sessionKey: string | undefined;
|
||||
let sessionEntry: SessionEntry | undefined;
|
||||
let sessionEntry: SessionEntry;
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let isNewSession = false;
|
||||
@@ -154,30 +156,30 @@ export async function initSessionState(params: {
|
||||
queueDrop: baseEntry?.queueDrop,
|
||||
displayName: baseEntry?.displayName,
|
||||
chatType: baseEntry?.chatType,
|
||||
surface: baseEntry?.surface,
|
||||
provider: baseEntry?.provider,
|
||||
subject: baseEntry?.subject,
|
||||
room: baseEntry?.room,
|
||||
space: baseEntry?.space,
|
||||
};
|
||||
if (groupResolution?.surface) {
|
||||
const surface = groupResolution.surface;
|
||||
if (groupResolution?.provider) {
|
||||
const provider = groupResolution.provider;
|
||||
const subject = ctx.GroupSubject?.trim();
|
||||
const space = ctx.GroupSpace?.trim();
|
||||
const explicitRoom = ctx.GroupRoom?.trim();
|
||||
const isRoomSurface = surface === "discord" || surface === "slack";
|
||||
const isRoomProvider = provider === "discord" || provider === "slack";
|
||||
const nextRoom =
|
||||
explicitRoom ??
|
||||
(isRoomSurface && subject && subject.startsWith("#")
|
||||
(isRoomProvider && subject && subject.startsWith("#")
|
||||
? subject
|
||||
: undefined);
|
||||
const nextSubject = nextRoom ? undefined : subject;
|
||||
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
||||
sessionEntry.surface = surface;
|
||||
sessionEntry.provider = provider;
|
||||
if (nextSubject) sessionEntry.subject = nextSubject;
|
||||
if (nextRoom) sessionEntry.room = nextRoom;
|
||||
if (space) sessionEntry.space = space;
|
||||
sessionEntry.displayName = buildGroupDisplayName({
|
||||
surface: sessionEntry.surface,
|
||||
provider: sessionEntry.provider,
|
||||
subject: sessionEntry.subject,
|
||||
room: sessionEntry.room,
|
||||
space: sessionEntry.space,
|
||||
|
||||
78
src/auto-reply/reply/typing.test.ts
Normal file
78
src/auto-reply/reply/typing.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTypingController } from "./typing.js";
|
||||
|
||||
describe("typing controller", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops after run completion and dispatcher idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onReplyStart = vi.fn(async () => {});
|
||||
const typing = createTypingController({
|
||||
onReplyStart,
|
||||
typingIntervalSeconds: 1,
|
||||
typingTtlMs: 30_000,
|
||||
});
|
||||
|
||||
await typing.startTypingLoop();
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(3);
|
||||
|
||||
typing.markRunComplete();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(4);
|
||||
|
||||
typing.markDispatchIdle();
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("keeps typing until both idle and run completion are set", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onReplyStart = vi.fn(async () => {});
|
||||
const typing = createTypingController({
|
||||
onReplyStart,
|
||||
typingIntervalSeconds: 1,
|
||||
typingTtlMs: 30_000,
|
||||
});
|
||||
|
||||
await typing.startTypingLoop();
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
typing.markDispatchIdle();
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(3);
|
||||
|
||||
typing.markRunComplete();
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not restart typing after it has stopped", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onReplyStart = vi.fn(async () => {});
|
||||
const typing = createTypingController({
|
||||
onReplyStart,
|
||||
typingIntervalSeconds: 1,
|
||||
typingTtlMs: 30_000,
|
||||
});
|
||||
|
||||
await typing.startTypingLoop();
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
typing.markRunComplete();
|
||||
typing.markDispatchIdle();
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Late callbacks should be ignored and must not restart the interval.
|
||||
await typing.startTypingOnText("late tool result");
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ export type TypingController = {
|
||||
startTypingLoop: () => Promise<void>;
|
||||
startTypingOnText: (text?: string) => Promise<void>;
|
||||
refreshTypingTtl: () => void;
|
||||
markRunComplete: () => void;
|
||||
markDispatchIdle: () => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
@@ -21,6 +23,13 @@ export function createTypingController(params: {
|
||||
log,
|
||||
} = params;
|
||||
let started = false;
|
||||
let active = false;
|
||||
let runComplete = false;
|
||||
let dispatchIdle = false;
|
||||
// Important: callbacks (tool/block streaming) can fire late (after the run completed),
|
||||
// especially when upstream event emitters don't await async listeners.
|
||||
// Once we stop typing, we "seal" the controller so late events can't restart typing forever.
|
||||
let sealed = false;
|
||||
let typingTimer: NodeJS.Timeout | undefined;
|
||||
let typingTtlTimer: NodeJS.Timeout | undefined;
|
||||
const typingIntervalMs = typingIntervalSeconds * 1000;
|
||||
@@ -30,7 +39,15 @@ export function createTypingController(params: {
|
||||
return `${Math.round(ms / 1000)}s`;
|
||||
};
|
||||
|
||||
const resetCycle = () => {
|
||||
started = false;
|
||||
active = false;
|
||||
runComplete = false;
|
||||
dispatchIdle = false;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (sealed) return;
|
||||
if (typingTtlTimer) {
|
||||
clearTimeout(typingTtlTimer);
|
||||
typingTtlTimer = undefined;
|
||||
@@ -39,9 +56,12 @@ export function createTypingController(params: {
|
||||
clearInterval(typingTimer);
|
||||
typingTimer = undefined;
|
||||
}
|
||||
resetCycle();
|
||||
sealed = true;
|
||||
};
|
||||
|
||||
const refreshTypingTtl = () => {
|
||||
if (sealed) return;
|
||||
if (!typingIntervalMs || typingIntervalMs <= 0) return;
|
||||
if (typingTtlMs <= 0) return;
|
||||
if (typingTtlTimer) {
|
||||
@@ -57,16 +77,30 @@ export function createTypingController(params: {
|
||||
};
|
||||
|
||||
const triggerTyping = async () => {
|
||||
if (sealed) return;
|
||||
await onReplyStart?.();
|
||||
};
|
||||
|
||||
const ensureStart = async () => {
|
||||
if (sealed) return;
|
||||
// Late callbacks after a run completed should never restart typing.
|
||||
if (runComplete) return;
|
||||
if (!active) {
|
||||
active = true;
|
||||
}
|
||||
if (started) return;
|
||||
started = true;
|
||||
await triggerTyping();
|
||||
};
|
||||
|
||||
const maybeStopOnIdle = () => {
|
||||
if (!active) return;
|
||||
// Stop only when the model run is done and the dispatcher queue is empty.
|
||||
if (runComplete && dispatchIdle) cleanup();
|
||||
};
|
||||
|
||||
const startTypingLoop = async () => {
|
||||
if (sealed) return;
|
||||
if (!onReplyStart) return;
|
||||
if (typingIntervalMs <= 0) return;
|
||||
if (typingTimer) return;
|
||||
@@ -78,6 +112,7 @@ export function createTypingController(params: {
|
||||
};
|
||||
|
||||
const startTypingOnText = async (text?: string) => {
|
||||
if (sealed) return;
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) return;
|
||||
if (silentToken && trimmed === silentToken) return;
|
||||
@@ -85,11 +120,23 @@ export function createTypingController(params: {
|
||||
await startTypingLoop();
|
||||
};
|
||||
|
||||
const markRunComplete = () => {
|
||||
runComplete = true;
|
||||
maybeStopOnIdle();
|
||||
};
|
||||
|
||||
const markDispatchIdle = () => {
|
||||
dispatchIdle = true;
|
||||
maybeStopOnIdle();
|
||||
};
|
||||
|
||||
return {
|
||||
onReplyStart: ensureStart,
|
||||
startTypingLoop,
|
||||
startTypingOnText,
|
||||
refreshTypingTtl,
|
||||
markRunComplete,
|
||||
markDispatchIdle,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function parseSendPolicyCommand(raw?: string): {
|
||||
if (!raw) return { hasCommand: false };
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { hasCommand: false };
|
||||
const match = trimmed.match(/^\/?send\b(?:\s+([a-zA-Z]+))?/i);
|
||||
const match = trimmed.match(/^\/send(?:\s+([a-zA-Z]+))?\s*$/i);
|
||||
if (!match) return { hasCommand: false };
|
||||
const token = match[1]?.trim().toLowerCase();
|
||||
if (!token) return { hasCommand: true };
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("buildStatusMessage", () => {
|
||||
verboseLevel: "on",
|
||||
compactionCount: 2,
|
||||
},
|
||||
sessionKey: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
storePath: "/tmp/sessions.json",
|
||||
resolvedThink: "medium",
|
||||
@@ -39,7 +39,7 @@ describe("buildStatusMessage", () => {
|
||||
expect(text).toContain("Agent: embedded pi");
|
||||
expect(text).toContain("Runtime: direct");
|
||||
expect(text).toContain("Context: 16k/32k (50%)");
|
||||
expect(text).toContain("Session: main");
|
||||
expect(text).toContain("Session: agent:main:main");
|
||||
expect(text).toContain("compactions 2");
|
||||
expect(text).toContain("Web: linked");
|
||||
expect(text).toContain("heartbeat 45s");
|
||||
@@ -70,7 +70,7 @@ describe("buildStatusMessage", () => {
|
||||
groupActivation: "always",
|
||||
chatType: "group",
|
||||
},
|
||||
sessionKey: "whatsapp:group:123@g.us",
|
||||
sessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
sessionScope: "per-sender",
|
||||
webLinked: true,
|
||||
});
|
||||
@@ -91,6 +91,8 @@ describe("buildStatusMessage", () => {
|
||||
const storePath = path.join(
|
||||
dir,
|
||||
".clawdbot",
|
||||
"agents",
|
||||
"main",
|
||||
"sessions",
|
||||
"sessions.json",
|
||||
);
|
||||
@@ -98,6 +100,8 @@ describe("buildStatusMessage", () => {
|
||||
const logPath = path.join(
|
||||
dir,
|
||||
".clawdbot",
|
||||
"agents",
|
||||
"main",
|
||||
"sessions",
|
||||
`${sessionId}.jsonl`,
|
||||
);
|
||||
@@ -135,7 +139,7 @@ describe("buildStatusMessage", () => {
|
||||
totalTokens: 3, // would be wrong if cached prompt tokens exist
|
||||
contextTokens: 32_000,
|
||||
},
|
||||
sessionKey: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
storePath,
|
||||
webLinked: true,
|
||||
|
||||
@@ -3,6 +3,8 @@ export type MsgContext = {
|
||||
From?: string;
|
||||
To?: string;
|
||||
SessionKey?: string;
|
||||
/** Provider account id (multi-account). */
|
||||
AccountId?: string;
|
||||
MessageSid?: string;
|
||||
ReplyToId?: string;
|
||||
ReplyToBody?: string;
|
||||
@@ -10,6 +12,9 @@ export type MsgContext = {
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
Transcript?: string;
|
||||
ChatType?: string;
|
||||
GroupSubject?: string;
|
||||
@@ -21,9 +26,13 @@ export type MsgContext = {
|
||||
SenderUsername?: string;
|
||||
SenderTag?: string;
|
||||
SenderE164?: string;
|
||||
/** Provider label (whatsapp|telegram|discord|imessage|...). */
|
||||
Provider?: string;
|
||||
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
|
||||
Surface?: string;
|
||||
WasMentioned?: boolean;
|
||||
CommandAuthorized?: boolean;
|
||||
CommandSource?: "text" | "native";
|
||||
};
|
||||
|
||||
export type TemplateContext = MsgContext & {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { TypingController } from "./reply/typing.js";
|
||||
|
||||
export type GetReplyOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
onTypingController?: (typing: TypingController) => void;
|
||||
isHeartbeat?: boolean;
|
||||
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
|
||||
onBlockReply?: (payload: ReplyPayload) => Promise<void> | void;
|
||||
@@ -11,4 +14,5 @@ export type ReplyPayload = {
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
replyToId?: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user