Compare commits

..
Author SHA1 Message Date
Peter Steinberger e13a332ac8 fix: narrow skills watcher ignores (#1074) (thanks @roshanasingh4) 2026-01-17 07:56:35 +00:00
Roshan Singh 6aaa302be5 Fix #1056: ignore heavy paths in skills watcher
On macOS, watching deep dependency trees can exhaust file descriptors and lead to spawn EBADF failures. The skills watcher only needs to observe skill changes, so ignore dotfiles, node_modules, and dist by default. Adds regression coverage.
2026-01-17 07:40:20 +00:00
80 changed files with 897 additions and 963 deletions

No files matched your search

+1 -4
View File
@@ -27,7 +27,6 @@
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
- Tools: add `process submit` helper to send CR for PTY sessions.
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
- Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI.
@@ -44,13 +43,12 @@
- Plugins: add zip installs and `--link` to avoid copying local paths.
### Fixes
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
- Skills: narrow skills watcher ignores to `.git` so hidden workspaces still refresh. (#1074) — thanks @roshanasingh4.
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
@@ -61,7 +59,6 @@
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
+3 -3
View File
@@ -16,14 +16,14 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
if (roomId.toLowerCase().startsWith("room:")) {
roomId = roomId.slice("room:".length).trim();
}
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : [];
const groupRoom = params.groupRoom?.trim() ?? "";
const aliases = groupRoom ? [groupRoom] : [];
const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.rooms,
roomId,
aliases,
name: groupChannel || undefined,
name: groupRoom || undefined,
}).config;
if (resolved) {
if (resolved.autoReply === true) return false;
@@ -379,7 +379,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
SenderId: senderId,
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
GroupRoom: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
Provider: "matrix" as const,
Surface: "matrix" as const,
+1 -1
View File
@@ -10,7 +10,7 @@ afterEach(() => {
test("exec supports pty output", async () => {
const tool = createExecTool({ allowBackground: false });
const result = await tool.execute("toolcall", {
command: 'node -e "process.stdout.write(String.fromCharCode(111,107))"',
command: "node -e 'process.stdout.write(\"ok\")'",
pty: true,
});
@@ -15,7 +15,7 @@ test("process send-keys encodes Enter for pty sessions", async () => {
const processTool = createProcessTool();
const result = await execTool.execute("toolcall", {
command:
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});"',
"node -e \"process.stdin.on('data', d => { process.stdout.write(d); if (d.includes(10) || d.includes(13)) process.exit(0); });\"",
pty: true,
background: true,
});
@@ -45,12 +45,12 @@ test("process send-keys encodes Enter for pty sessions", async () => {
throw new Error("PTY session did not exit after send-keys");
});
test("process submit sends Enter for pty sessions", async () => {
test("process submit sends CR for pty sessions", async () => {
const execTool = createExecTool();
const processTool = createProcessTool();
const result = await execTool.execute("toolcall", {
command:
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});"',
"node -e \"process.stdin.on('data', d => { if (d.includes(10) || d.includes(13)) { process.stdout.write('submitted'); process.exit(0); } });\"",
pty: true,
background: true,
});
@@ -64,8 +64,7 @@ test("process submit sends Enter for pty sessions", async () => {
sessionId,
});
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
while (Date.now() < deadline) {
for (let i = 0; i < 10; i += 1) {
await wait(50);
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
const details = poll.details as { status?: string; aggregated?: string };
+7 -2
View File
@@ -33,7 +33,9 @@ const processSchema = Type.Object({
keys: Type.Optional(
Type.Array(Type.String(), { description: "Key tokens to send for send-keys" }),
),
hex: Type.Optional(Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" })),
hex: Type.Optional(
Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" }),
),
literal: Type.Optional(Type.String({ description: "Literal string for send-keys" })),
text: Type.Optional(Type.String({ description: "Text to paste for paste" })),
bracketed: Type.Optional(Type.Boolean({ description: "Wrap paste in bracketed mode" })),
@@ -119,7 +121,10 @@ export function createProcessTool(
.sort((a, b) => b.startedAt - a.startedAt)
.map((s) => {
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
return `${s.sessionId} ${pad(
s.status,
9,
)} ${formatDuration(s.runtimeMs)} :: ${label}`;
});
return {
content: [
+1 -1
View File
@@ -1,4 +1,4 @@
const ESC = String.fromCharCode(0x1b);
const ESC = "\u001b";
const DSR_PATTERN = new RegExp(`${ESC}\\[\\??6n`, "g");
export function stripDsrRequests(input: string): { cleaned: string; requests: number } {
+1 -6
View File
@@ -1,11 +1,6 @@
import { expect, test } from "vitest";
import {
BRACKETED_PASTE_END,
BRACKETED_PASTE_START,
encodeKeySequence,
encodePaste,
} from "./pty-keys.js";
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, encodeKeySequence, encodePaste } from "./pty-keys.js";
test("encodeKeySequence maps common keys and modifiers", () => {
const enter = encodeKeySequence({ keys: ["Enter"] });
+2 -2
View File
@@ -19,8 +19,8 @@ describe("ensureSkillsWatcher", () => {
expect(watchMock).toHaveBeenCalledTimes(1);
const opts = watchMock.mock.calls[0]?.[1] as { ignored?: unknown };
expect(opts.ignored).toBe(mod.DEFAULT_SKILLS_WATCH_IGNORED);
const ignored = mod.DEFAULT_SKILLS_WATCH_IGNORED;
expect(Array.isArray(opts.ignored)).toBe(true);
const ignored = opts.ignored as RegExp[];
expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe(
true,
);
+5 -7
View File
@@ -26,12 +26,6 @@ const workspaceVersions = new Map<string, number>();
const watchers = new Map<string, SkillsWatchState>();
let globalVersion = 0;
export const DEFAULT_SKILLS_WATCH_IGNORED: RegExp[] = [
/(^|[\\/])\.git([\\/]|$)/,
/(^|[\\/])node_modules([\\/]|$)/,
/(^|[\\/])dist([\\/]|$)/,
];
function bumpVersion(current: number): number {
const now = Date.now();
return now <= current ? current + 1 : now;
@@ -133,7 +127,11 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
},
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
// This watcher only needs to react to skill changes.
ignored: DEFAULT_SKILLS_WATCH_IGNORED,
ignored: [
/(^|[\\/])\.git([\\/]|$)/,
/(^|[\\/])node_modules([\\/]|$)/,
/(^|[\\/])dist([\\/]|$)/,
],
});
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
+3 -3
View File
@@ -150,12 +150,12 @@ export function createSessionsListTool(opts?: {
: undefined;
const deliveryChannel =
typeof deliveryContext?.channel === "string" ? deliveryContext.channel : undefined;
const deliveryTo = typeof deliveryContext?.to === "string" ? deliveryContext.to : undefined;
const deliveryTo =
typeof deliveryContext?.to === "string" ? deliveryContext.to : undefined;
const deliveryAccountId =
typeof deliveryContext?.accountId === "string" ? deliveryContext.accountId : undefined;
const lastChannel =
deliveryChannel ??
(typeof entry.lastChannel === "string" ? entry.lastChannel : undefined);
deliveryChannel ?? (typeof entry.lastChannel === "string" ? entry.lastChannel : undefined);
const lastAccountId =
deliveryAccountId ??
(typeof entry.lastAccountId === "string" ? entry.lastAccountId : undefined);
-36
View File
@@ -382,42 +382,6 @@ describe("handleTelegramAction", () => {
).rejects.toThrow(/inline buttons are limited to DMs/i);
});
it("allows inline buttons in DMs with tg: prefixed targets", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", capabilities: { inlineButtons: "dm" } },
},
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "sendMessage",
to: "tg:5232990709",
content: "Choose",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalled();
});
it("allows inline buttons in groups with topic targets", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", capabilities: { inlineButtons: "group" } },
},
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "sendMessage",
to: "telegram:group:-1001234567890:topic:456",
content: "Choose",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalled();
});
it("sends messages with inline keyboard buttons when enabled", async () => {
const cfg = {
channels: {
+3 -1
View File
@@ -297,7 +297,9 @@ export async function runPreparedReply(
abortKey: command.abortKey,
messageId: sessionCtx.MessageSid,
});
const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "channel";
const isGroupSession =
sessionEntry?.chatType === "group" ||
sessionEntry?.chatType === "channel";
const isMainSession = !isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey);
prefixedBodyBase = await prependSystemEvents({
cfg,
+1 -1
View File
@@ -23,7 +23,7 @@ describe("resolveGroupRequireMention", () => {
const ctx: TemplateContext = {
Provider: "discord",
From: "group:123",
GroupChannel: "#general",
GroupRoom: "#general",
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
+4 -4
View File
@@ -16,12 +16,12 @@ export function resolveGroupRequireMention(params: {
const channel = normalizeChannelId(rawChannel);
if (!channel) return true;
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim();
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
cfg,
groupId,
groupChannel,
groupRoom,
groupSpace,
accountId: ctx.AccountId,
});
@@ -62,13 +62,13 @@ export function buildGroupIntro(params: {
? "Activation: always-on (you receive every group message)."
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
const groupId = params.sessionCtx.From?.replace(/^group:/, "");
const groupChannel = params.sessionCtx.GroupChannel?.trim() ?? subject;
const groupRoom = params.sessionCtx.GroupRoom?.trim() ?? subject;
const groupSpace = params.sessionCtx.GroupSpace?.trim();
const providerIdsLine = providerId
? getChannelDock(providerId)?.groups?.resolveGroupIntroHint?.({
cfg: params.cfg,
groupId,
groupChannel,
groupRoom,
groupSpace,
accountId: params.sessionCtx.AccountId,
})
+4 -1
View File
@@ -1,4 +1,7 @@
import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js";
import {
applyQueueDropPolicy,
shouldSkipQueueItem,
} from "../../../utils/queue-helpers.js";
import { FOLLOWUP_QUEUES, getFollowupQueue } from "./state.js";
import type { FollowupRun, QueueDedupeMode, QueueSettings } from "./types.js";
+7 -10
View File
@@ -193,9 +193,11 @@ export async function initSessionState(params: {
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
// Track the originating channel/to for announce routing (subagent announce-back).
const lastChannelRaw = (ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel;
const lastChannelRaw =
(ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel;
const lastToRaw = (ctx.OriginatingTo as string | undefined) || ctx.To || baseEntry?.lastTo;
const lastAccountIdRaw = (ctx.AccountId as string | undefined) || baseEntry?.lastAccountId;
const lastAccountIdRaw =
(ctx.AccountId as string | undefined) || baseEntry?.lastAccountId;
const deliveryFields = normalizeSessionDeliveryFields({
deliveryContext: {
channel: lastChannelRaw,
@@ -240,19 +242,14 @@ export async function initSessionState(params: {
const channel = groupResolution.channel;
const subject = ctx.GroupSubject?.trim();
const space = ctx.GroupSpace?.trim();
const explicitChannel = ctx.GroupChannel?.trim();
const explicitRoom = ctx.GroupRoom?.trim();
const normalizedChannel = normalizeChannelId(channel);
const isChannelProvider = Boolean(
const isRoomProvider = Boolean(
normalizedChannel &&
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"),
);
const nextRoom =
explicitChannel ??
((groupResolution.chatType === "channel" || isChannelProvider) &&
subject &&
subject.startsWith("#")
? subject
: undefined);
explicitRoom ?? (isRoomProvider && subject && subject.startsWith("#") ? subject : undefined);
const nextSubject = nextRoom ? undefined : subject;
sessionEntry.chatType = groupResolution.chatType ?? "group";
sessionEntry.channel = channel;
+1 -2
View File
@@ -63,8 +63,7 @@ export type MsgContext = {
/** Human label for envelope headers (conversation label, not sender). */
ConversationLabel?: string;
GroupSubject?: string;
/** Human label for channel-like group conversations (e.g. #general, #support). */
GroupChannel?: string;
GroupRoom?: string;
GroupSpace?: string;
GroupMembers?: string;
GroupSystemPrompt?: string;
-56
View File
@@ -90,23 +90,6 @@ function createMessageQueue(ws: WebSocket) {
return { next };
}
async function waitForListMatch<T>(
fetchList: () => Promise<T>,
predicate: (value: T) => boolean,
timeoutMs = 2000,
intervalMs = 50,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (true) {
const value = await fetchList();
if (predicate(value)) return value;
if (Date.now() >= deadline) {
throw new Error("timeout waiting for list update");
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
describe("chrome extension relay server", () => {
let cdpUrl = "";
@@ -169,48 +152,9 @@ describe("chrome extension relay server", () => {
const list = (await fetch(`${cdpUrl}/json/list`).then((r) => r.json())) as Array<{
id?: string;
url?: string;
title?: string;
}>;
expect(list.some((t) => t.id === "t1" && t.url === "https://example.com")).toBe(true);
// Simulate navigation updating tab metadata.
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.targetInfoChanged",
params: {
targetInfo: {
targetId: "t1",
type: "page",
title: "DER STANDARD",
url: "https://www.derstandard.at/",
},
},
},
}),
);
const list2 = await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`).then((r) => r.json())) as Array<{
id?: string;
url?: string;
title?: string;
}>,
(list) =>
list.some(
(t) =>
t.id === "t1" && t.url === "https://www.derstandard.at/" && t.title === "DER STANDARD",
),
);
expect(
list2.some(
(t) =>
t.id === "t1" && t.url === "https://www.derstandard.at/" && t.title === "DER STANDARD",
),
).toBe(true);
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
await waitForOpen(cdp);
const q = createMessageQueue(cdp);
-17
View File
@@ -497,23 +497,6 @@ export async function ensureChromeExtensionRelayServer(opts: {
return;
}
// Keep cached tab metadata fresh for /json/list.
// After navigation, Chrome updates URL/title via Target.targetInfoChanged.
if (method === "Target.targetInfoChanged") {
const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } };
const targetInfo = changed?.targetInfo;
const targetId = targetInfo?.targetId;
if (targetId && (targetInfo?.type ?? "page") === "page") {
for (const [sid, target] of connectedTargets) {
if (target.targetId !== targetId) continue;
connectedTargets.set(sid, {
...target,
targetInfo: { ...target.targetInfo, ...(targetInfo as object) },
});
}
}
}
broadcastToCdpClients({ method, params, sessionId });
}
});
+1
View File
@@ -70,3 +70,4 @@ describe("resolveCommandAuthorizedFromAuthorizers", () => {
).toBe(true);
});
});
+1
View File
@@ -21,3 +21,4 @@ export function resolveCommandAuthorizedFromAuthorizers(params: {
}
return authorizers.some((entry) => entry.configured && entry.allowed);
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function resolveConversationLabel(ctx: MsgContext): string | undefined {
}
const base =
ctx.GroupChannel?.trim() ||
ctx.GroupRoom?.trim() ||
ctx.GroupSubject?.trim() ||
ctx.GroupSpace?.trim() ||
ctx.From?.trim() ||
+4 -1
View File
@@ -26,7 +26,10 @@ import {
} from "./config-helpers.js";
import { resolveDiscordGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget } from "./normalize-target.js";
import {
looksLikeDiscordTargetId,
normalizeDiscordMessagingTarget,
} from "./normalize-target.js";
import { discordOnboardingAdapter } from "./onboarding/discord.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
+4 -6
View File
@@ -6,7 +6,7 @@ import { resolveSlackAccount } from "../../slack/accounts.js";
type GroupMentionParams = {
cfg: ClawdbotConfig;
groupId?: string | null;
groupChannel?: string | null;
groupRoom?: string | null;
groupSpace?: string | null;
accountId?: string | null;
};
@@ -133,14 +133,13 @@ export function resolveDiscordGroupRequireMention(params: GroupMentionParams): b
);
const channelEntries = guildEntry?.channels;
if (channelEntries && Object.keys(channelEntries).length > 0) {
const groupChannel = params.groupChannel;
const channelSlug = normalizeDiscordSlug(groupChannel);
const channelSlug = normalizeDiscordSlug(params.groupRoom);
const entry =
(params.groupId ? channelEntries[params.groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
(params.groupRoom ? channelEntries[normalizeDiscordSlug(params.groupRoom)] : undefined);
if (entry && typeof entry.requireMention === "boolean") {
return entry.requireMention;
}
@@ -160,8 +159,7 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo
const keys = Object.keys(channels);
if (keys.length === 0) return true;
const channelId = params.groupId?.trim();
const groupChannel = params.groupChannel;
const channelName = groupChannel?.replace(/^#/, "");
const channelName = params.groupRoom?.replace(/^#/, "");
const normalizedName = normalizeSlackSlug(channelName);
const candidates = [
channelId ?? "",
+4 -1
View File
@@ -18,7 +18,10 @@ import {
} from "./config-helpers.js";
import { formatPairingApproveHint } from "./helpers.js";
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize-target.js";
import {
looksLikeSignalTargetId,
normalizeSignalMessagingTarget,
} from "./normalize-target.js";
import { signalOnboardingAdapter } from "./onboarding/signal.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
+4 -1
View File
@@ -26,7 +26,10 @@ import {
} from "./config-helpers.js";
import { resolveTelegramGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize-target.js";
import {
looksLikeTelegramTargetId,
normalizeTelegramMessagingTarget,
} from "./normalize-target.js";
import { telegramOnboardingAdapter } from "./onboarding/telegram.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
+1 -2
View File
@@ -133,8 +133,7 @@ export type ChannelLogSink = {
export type ChannelGroupContext = {
cfg: ClawdbotConfig;
groupId?: string | null;
/** Human label for channel-like group conversations (e.g. #general). */
groupChannel?: string | null;
groupRoom?: string | null;
groupSpace?: string | null;
accountId?: string | null;
};
+4 -1
View File
@@ -26,7 +26,10 @@ import { buildChannelConfigSchema } from "./config-schema.js";
import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js";
import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize-target.js";
import {
looksLikeWhatsAppTargetId,
normalizeWhatsAppMessagingTarget,
} from "./normalize-target.js";
import { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js";
import {
applyAccountNameToChannelSection,
+1 -3
View File
@@ -129,9 +129,7 @@ export function normalizeAnyChannelId(raw?: string | null): ChannelId | null {
if (!registry) return null;
const hit = registry.channels.find((entry) => {
const id = String(entry.plugin.id ?? "")
.trim()
.toLowerCase();
const id = String(entry.plugin.id ?? "").trim().toLowerCase();
if (id && id === key) return true;
return (entry.plugin.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === key);
});
+3 -6
View File
@@ -12,11 +12,7 @@ import {
type HookStatusReport,
} from "../hooks/hooks-status.js";
import { loadConfig, writeConfigFile } from "../config/io.js";
import {
installHooksFromNpmSpec,
installHooksFromPath,
resolveHookInstallDir,
} from "../hooks/install.js";
import { installHooksFromNpmSpec, installHooksFromPath, resolveHookInstallDir } from "../hooks/install.js";
import { recordHookInstall } from "../hooks/installs.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
@@ -736,7 +732,8 @@ export function registerHooksCli(program: Command): void {
continue;
}
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
const nextVersion =
result.version ?? (await readInstalledPackageVersion(result.targetDir));
nextCfg = recordHookInstall(nextCfg, {
hookId,
source: "npm",
+1 -3
View File
@@ -51,9 +51,7 @@ export async function deliverAgentCommandResult(params: {
isInternalMessageChannel(deliveryChannel) || Boolean(deliveryPlugin);
const targetMode =
opts.deliveryTargetMode ??
deliveryPlan.deliveryTargetMode ??
(opts.to ? "explicit" : "implicit");
opts.deliveryTargetMode ?? deliveryPlan.deliveryTargetMode ?? (opts.to ? "explicit" : "implicit");
const resolvedAccountId = deliveryPlan.resolvedAccountId;
const resolved =
deliver && isDeliveryChannelKnown && deliveryChannel
+4 -1
View File
@@ -243,7 +243,10 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
const results = result.payload.results ?? [];
const rows = results.map((entry) => ({
Channel: resolveChannelLabel(entry.channel),
Target: shortenText(formatTargetDisplay({ channel: entry.channel, target: entry.to }), 36),
Target: shortenText(
formatTargetDisplay({ channel: entry.channel, target: entry.to }),
36,
),
Status: entry.ok ? "ok" : "error",
Error: entry.ok ? "" : shortenText(entry.error ?? "unknown error", 48),
}));
+7 -7
View File
@@ -16,8 +16,8 @@ describe("resolveChannelCapabilities", () => {
it("returns undefined for missing inputs", () => {
expect(resolveChannelCapabilities({})).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {} })).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {}, channel: "" })).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {} as ClawdbotConfig })).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {} as ClawdbotConfig, channel: "" })).toBeUndefined();
});
it("normalizes and prefers per-account capabilities", () => {
@@ -36,7 +36,7 @@ describe("resolveChannelCapabilities", () => {
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "telegram",
accountId: "default",
}),
@@ -57,7 +57,7 @@ describe("resolveChannelCapabilities", () => {
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "telegram",
accountId: "default",
}),
@@ -77,7 +77,7 @@ describe("resolveChannelCapabilities", () => {
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "slack",
accountId: "family",
}),
@@ -100,7 +100,7 @@ describe("resolveChannelCapabilities", () => {
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "msteams",
}),
).toEqual(["polls"]);
@@ -120,7 +120,7 @@ describe("resolveChannelCapabilities", () => {
// Should return undefined (not crash), allowing channel-specific handlers to process it.
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "telegram",
}),
).toBeUndefined();
+8 -13
View File
@@ -1,24 +1,19 @@
import { normalizeChannelId } from "../channels/plugins/index.js";
import { normalizeAccountId } from "../routing/session-key.js";
import type { ClawdbotConfig } from "./config.js";
import type { TelegramCapabilitiesConfig } from "./types.telegram.js";
type CapabilitiesConfig = TelegramCapabilitiesConfig;
const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((entry) => typeof entry === "string");
function normalizeCapabilities(capabilities: CapabilitiesConfig | undefined): string[] | undefined {
function normalizeCapabilities(capabilities: string[] | undefined): string[] | undefined {
if (!capabilities) return undefined;
// Handle object-format capabilities (e.g., { inlineButtons: "dm" }) gracefully.
// Channel-specific handlers (like resolveTelegramInlineButtonsScope) process these separately.
if (!isStringArray(capabilities)) return undefined;
if (!Array.isArray(capabilities)) return undefined;
const normalized = capabilities.map((entry) => entry.trim()).filter(Boolean);
return normalized.length > 0 ? normalized : undefined;
}
function resolveAccountCapabilities(params: {
cfg?: { accounts?: Record<string, { capabilities?: CapabilitiesConfig }> } & {
capabilities?: CapabilitiesConfig;
cfg?: { accounts?: Record<string, { capabilities?: string[] }> } & {
capabilities?: string[];
};
accountId?: string | null;
}): string[] | undefined {
@@ -45,7 +40,7 @@ function resolveAccountCapabilities(params: {
}
export function resolveChannelCapabilities(params: {
cfg?: Partial<ClawdbotConfig>;
cfg?: ClawdbotConfig;
channel?: string | null;
accountId?: string | null;
}): string[] | undefined {
@@ -56,8 +51,8 @@ export function resolveChannelCapabilities(params: {
const channelsConfig = cfg.channels as Record<string, unknown> | undefined;
const channelConfig = (channelsConfig?.[channel] ?? (cfg as Record<string, unknown>)[channel]) as
| {
accounts?: Record<string, { capabilities?: CapabilitiesConfig }>;
capabilities?: CapabilitiesConfig;
accounts?: Record<string, { capabilities?: string[] }>;
capabilities?: string[];
}
| undefined;
return resolveAccountCapabilities({
+5 -1
View File
@@ -251,7 +251,11 @@ export const MediaUnderstandingScopeSchema = z
.object({
channel: z.string().optional(),
chatType: z
.union([z.literal("direct"), z.literal("group"), z.literal("channel")])
.union([
z.literal("direct"),
z.literal("group"),
z.literal("channel"),
])
.optional(),
keyPrefix: z.string().optional(),
})
+5 -1
View File
@@ -39,7 +39,11 @@ export const SessionSchema = z
.object({
channel: z.string().optional(),
chatType: z
.union([z.literal("direct"), z.literal("group"), z.literal("channel")])
.union([
z.literal("direct"),
z.literal("group"),
z.literal("channel"),
])
.optional(),
keyPrefix: z.string().optional(),
})
@@ -319,38 +319,38 @@ export async function preflightDiscordMessage(
surface: "discord",
});
if (!isDirectMessage) {
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
const ownerOk = ownerAllowList
? allowListMatches(ownerAllowList, {
if (!isDirectMessage) {
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
const ownerOk = ownerAllowList
? allowListMatches(ownerAllowList, {
id: author.id,
name: author.username,
tag: formatDiscordUserTag(author),
})
: false;
const channelUsers = channelConfig?.users ?? guildInfo?.users;
const usersOk =
Array.isArray(channelUsers) && channelUsers.length > 0
? resolveDiscordUserAllowed({
allowList: channelUsers,
userId: author.id,
userName: author.username,
userTag: formatDiscordUserTag(author),
})
: false;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk },
],
modeWhenAccessGroupsOff: "configured",
});
const channelUsers = channelConfig?.users ?? guildInfo?.users;
const usersOk =
Array.isArray(channelUsers) && channelUsers.length > 0
? resolveDiscordUserAllowed({
allowList: channelUsers,
userId: author.id,
userName: author.username,
userTag: formatDiscordUserTag(author),
})
: false;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk },
],
modeWhenAccessGroupsOff: "configured",
});
if (allowTextCommands && hasControlCommand(baseText, params.cfg) && !commandAuthorized) {
logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`);
return null;
if (allowTextCommands && hasControlCommand(baseText, params.cfg) && !commandAuthorized) {
logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`);
return null;
}
}
@@ -124,8 +124,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
senderDisplay && senderTag && senderDisplay !== senderTag
? `${senderDisplay} (${senderTag})`
: (senderDisplay ?? senderTag ?? author.id);
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel;
const groupRoom = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupRoom;
const channelDescription = channelInfo?.topic?.trim();
const systemPromptParts = [
channelDescription ? `Channel topic: ${channelDescription}` : null,
@@ -245,7 +245,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
SenderUsername: author.username,
SenderTag: formatDiscordUserTag(author),
GroupSubject: groupSubject,
GroupChannel: groupChannel,
GroupRoom: groupRoom,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
Provider: "discord" as const,
+3 -2
View File
@@ -1,7 +1,7 @@
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js";
import { toAgentRequestSessionKey } from "../routing/session-key.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
export function resolveSessionKeyForRun(runId: string) {
const cached = getAgentRunContext(runId)?.sessionKey;
@@ -12,7 +12,8 @@ export function resolveSessionKeyForRun(runId: string) {
const found = Object.entries(store).find(([, entry]) => entry?.sessionId === runId);
const storeKey = found?.[0];
if (storeKey) {
const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey;
const parsed = parseAgentSessionKey(storeKey);
const sessionKey = parsed?.rest ?? storeKey;
registerAgentRunContext(runId, { sessionKey });
return sessionKey;
}
@@ -9,7 +9,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -27,16 +26,22 @@ describe("gateway server agent", () => {
testState.allowFrom = ["+436769770569"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-stale",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-stale",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -65,14 +70,20 @@ describe("gateway server agent", () => {
test("agent forwards sessionKey to agentCommand", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
"agent:main:subagent:abc": {
sessionId: "sess-sub",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:subagent:abc": {
sessionId: "sess-sub",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -100,17 +111,23 @@ describe("gateway server agent", () => {
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-account",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "default",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-account",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "default",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -139,17 +156,23 @@ describe("gateway server agent", () => {
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-explicit",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "legacy",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-explicit",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "legacy",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -178,17 +201,23 @@ describe("gateway server agent", () => {
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-explicit-account",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "legacy",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-explicit-account",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "legacy",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -218,17 +247,23 @@ describe("gateway server agent", () => {
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-implicit",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "kev",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-implicit",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "kev",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -255,14 +290,20 @@ describe("gateway server agent", () => {
test("agent forwards image attachments as images[]", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-images",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-images",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -302,14 +343,20 @@ describe("gateway server agent", () => {
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-missing-provider",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-missing-provider",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -337,16 +384,22 @@ describe("gateway server agent", () => {
test("agent routes main last-channel whatsapp", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-whatsapp",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-whatsapp",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -376,16 +429,22 @@ describe("gateway server agent", () => {
test("agent routes main last-channel telegram", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "telegram",
lastTo: "123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "telegram",
lastTo: "123",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -414,16 +473,22 @@ describe("gateway server agent", () => {
test("agent routes main last-channel discord", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-discord",
updatedAt: Date.now(),
lastChannel: "discord",
lastTo: "channel:discord-123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-discord",
updatedAt: Date.now(),
lastChannel: "discord",
lastTo: "channel:discord-123",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -452,16 +517,22 @@ describe("gateway server agent", () => {
test("agent routes main last-channel slack", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-slack",
updatedAt: Date.now(),
lastChannel: "slack",
lastTo: "channel:slack-123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-slack",
updatedAt: Date.now(),
lastChannel: "slack",
lastTo: "channel:slack-123",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -490,16 +561,22 @@ describe("gateway server agent", () => {
test("agent routes main last-channel signal", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-signal",
updatedAt: Date.now(),
lastChannel: "signal",
lastTo: "+15551234567",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-signal",
updatedAt: Date.now(),
lastChannel: "signal",
lastTo: "+15551234567",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -18,7 +18,6 @@ import {
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -81,16 +80,22 @@ describe("gateway server agent", () => {
setActivePluginRegistry(registry);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-teams",
updatedAt: Date.now(),
lastChannel: "msteams",
lastTo: "conversation:teams-123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-teams",
updatedAt: Date.now(),
lastChannel: "msteams",
lastTo: "conversation:teams-123",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -128,16 +133,22 @@ describe("gateway server agent", () => {
setActivePluginRegistry(registry);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-alias",
updatedAt: Date.now(),
lastChannel: "imessage",
lastTo: "chat_id:123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-alias",
updatedAt: Date.now(),
lastChannel: "imessage",
lastTo: "chat_id:123",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -195,16 +206,22 @@ describe("gateway server agent", () => {
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-webchat",
updatedAt: Date.now(),
lastChannel: "webchat",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-webchat",
updatedAt: Date.now(),
lastChannel: "webchat",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -233,16 +250,22 @@ describe("gateway server agent", () => {
test("agent uses webchat for internal runs when last provider is webchat", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-webchat-internal",
updatedAt: Date.now(),
lastChannel: "webchat",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-webchat-internal",
updatedAt: Date.now(),
lastChannel: "webchat",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -385,14 +408,20 @@ describe("gateway server agent", () => {
test("agent events stream to webchat clients when run context is registered", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
@@ -11,7 +11,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -67,15 +66,21 @@ describe("gateway server agent", () => {
test("suppresses tool stream events when verbose is off", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
verboseLevel: "off",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
verboseLevel: "off",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
@@ -11,7 +11,6 @@ import {
sessionStoreSaveDelayMs,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -29,14 +28,20 @@ describe("gateway server chat", () => {
test("chat.history caps payload bytes", { timeout: 15_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -73,16 +78,22 @@ describe("gateway server chat", () => {
test("chat.send does not overwrite last delivery route", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -94,12 +105,11 @@ describe("gateway server chat", () => {
});
expect(res.ok).toBe(true);
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as Record<
string,
{ lastChannel?: string; lastTo?: string } | undefined
>;
expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp");
expect(stored["agent:main:main"]?.lastTo).toBe("+1555");
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as {
main?: { lastChannel?: string; lastTo?: string };
};
expect(stored.main?.lastChannel).toBe("whatsapp");
expect(stored.main?.lastTo).toBe("+1555");
ws.close();
await server.close();
@@ -108,14 +118,20 @@ describe("gateway server chat", () => {
test("chat.abort cancels an in-flight chat.send", { timeout: 15000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
let inFlight: Promise<unknown> | undefined;
@@ -194,14 +210,20 @@ describe("gateway server chat", () => {
test("chat.abort cancels while saving the session store", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
sessionStoreSaveDelayMs.value = 120;
@@ -266,11 +288,11 @@ describe("gateway server chat", () => {
test("chat.send treats /stop as an out-of-band abort", { timeout: 15000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
},
});
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify({ main: { sessionId: "sess-main", updatedAt: Date.now() } }, null, 2),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -11,7 +11,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -97,7 +96,7 @@ describe("gateway server chat", () => {
test("chat.abort returns aborted=false for unknown runId", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ entries: {} });
await fs.writeFile(testState.sessionStorePath, JSON.stringify({}, null, 2), "utf-8");
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -117,14 +116,20 @@ describe("gateway server chat", () => {
test("chat.abort rejects mismatched sessionKey", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -184,14 +189,20 @@ describe("gateway server chat", () => {
test("chat.abort is a no-op after chat.send completes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -248,14 +259,20 @@ describe("gateway server chat", () => {
test("chat.send preserves run ordering for queued runs", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -12,7 +12,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -107,16 +106,22 @@ describe("gateway server chat", () => {
},
};
await writeSessionStore({
entries: {
"discord:group:dev": {
sessionId: "sess-discord",
updatedAt: Date.now(),
chatType: "group",
channel: "discord",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"discord:group:dev": {
sessionId: "sess-discord",
updatedAt: Date.now(),
chatType: "group",
channel: "discord",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -143,14 +148,20 @@ describe("gateway server chat", () => {
},
};
await writeSessionStore({
entries: {
"cron:job-1": {
sessionId: "sess-cron",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"cron:job-1": {
sessionId: "sess-cron",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -225,14 +236,20 @@ describe("gateway server chat", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const lines: string[] = [];
for (let i = 0; i < 300; i += 1) {
@@ -332,15 +349,21 @@ describe("gateway server chat", () => {
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
sessionFile: forkedPath,
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
sessionFile: forkedPath,
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -374,14 +397,20 @@ describe("gateway server chat", () => {
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -418,14 +447,20 @@ describe("gateway server chat", () => {
];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
JSON.stringify({
@@ -16,7 +16,6 @@ import {
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
const _decodeWsData = (data: unknown): string => {
@@ -218,14 +217,20 @@ describe("gateway server node/bridge", () => {
test("bridge RPC chat.history returns session messages", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
@@ -269,14 +274,20 @@ describe("gateway server node/bridge", () => {
test("bridge RPC sessions.list returns session rows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -320,14 +331,20 @@ describe("gateway server node/bridge", () => {
test("bridge chat events are pushed to subscribed nodes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -391,14 +408,20 @@ describe("gateway server node/bridge", () => {
test("bridge chat.send forwards image attachments to agentCommand", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -14,7 +14,6 @@ import {
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
const decodeWsData = (data: unknown): string => {
@@ -43,16 +42,22 @@ describe("gateway server node/bridge", () => {
test("bridge voice transcript defaults to main session", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -87,14 +92,20 @@ describe("gateway server node/bridge", () => {
test("bridge voice transcript triggers chat events for webchat clients", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
@@ -172,14 +183,20 @@ describe("gateway server node/bridge", () => {
test("bridge chat.abort cancels while saving the session store", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
sessionStoreSaveDelayMs.value = 120;
@@ -10,7 +10,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
@@ -66,35 +65,41 @@ describe("gateway server sessions", () => {
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: now - 30_000,
inputTokens: 10,
outputTokens: 20,
thinkingLevel: "low",
verboseLevel: "on",
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "work",
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: now - 30_000,
inputTokens: 10,
outputTokens: 20,
thinkingLevel: "low",
verboseLevel: "on",
lastProvider: "whatsapp",
lastTo: "+1555",
lastAccountId: "work",
},
"agent:main:discord:group:dev": {
sessionId: "sess-group",
updatedAt: now - 120_000,
totalTokens: 50,
},
"agent:main:subagent:one": {
sessionId: "sess-subagent",
updatedAt: now - 120_000,
spawnedBy: "agent:main:main",
},
global: {
sessionId: "sess-global",
updatedAt: now - 10_000,
},
},
"discord:group:dev": {
sessionId: "sess-group",
updatedAt: now - 120_000,
totalTokens: 50,
},
"agent:main:subagent:one": {
sessionId: "sess-subagent",
updatedAt: now - 120_000,
spawnedBy: "agent:main:main",
},
global: {
sessionId: "sess-global",
updatedAt: now - 10_000,
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
const hello = await connectOk(ws);
@@ -350,15 +355,21 @@ describe("gateway server sessions", () => {
"utf-8",
);
await writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
"discord:group:dev": {
sessionId: "sess-active",
updatedAt: Date.now(),
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": { sessionId: "sess-main", updatedAt: Date.now() },
"agent:main:discord:group:dev": {
sessionId: "sess-active",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
embeddedRunMock.activeIds.add("sess-active");
embeddedRunMock.waitResults.set("sess-active", true);
@@ -8,7 +8,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -26,30 +25,38 @@ describe("gateway server sessions", () => {
const workDir = path.join(dir, "work");
await fs.mkdir(homeDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await writeSessionStore({
storePath: path.join(homeDir, "sessions.json"),
agentId: "home",
entries: {
main: {
sessionId: "sess-home-main",
updatedAt: Date.now(),
await fs.writeFile(
path.join(homeDir, "sessions.json"),
JSON.stringify(
{
"agent:home:main": {
sessionId: "sess-home-main",
updatedAt: Date.now(),
},
"agent:home:discord:group:dev": {
sessionId: "sess-home-group",
updatedAt: Date.now() - 1000,
},
},
"discord:group:dev": {
sessionId: "sess-home-group",
updatedAt: Date.now() - 1000,
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(workDir, "sessions.json"),
JSON.stringify(
{
"agent:work:main": {
sessionId: "sess-work-main",
updatedAt: Date.now(),
},
},
},
});
await writeSessionStore({
storePath: path.join(workDir, "sessions.json"),
agentId: "work",
entries: {
main: {
sessionId: "sess-work-main",
updatedAt: Date.now(),
},
},
});
null,
2,
),
"utf-8",
);
const { ws } = await startServerWithClient();
await connectOk(ws);
@@ -85,17 +92,20 @@ describe("gateway server sessions", () => {
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
testState.sessionConfig = { mainKey: "work" };
await writeSessionStore({
await fs.writeFile(
storePath,
agentId: "ops",
mainKey: "work",
entries: {
main: {
sessionId: "sess-ops-main",
updatedAt: Date.now(),
JSON.stringify(
{
"agent:ops:work": {
sessionId: "sess-ops-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { ws } = await startServerWithClient();
await connectOk(ws);
+1 -28
View File
@@ -6,12 +6,11 @@ import path from "node:path";
import { afterEach, beforeEach, expect } from "vitest";
import { WebSocket } from "ws";
import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js";
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { rawDataToString } from "../infra/ws.js";
import { resetLogger, setLoggerOverride } from "../logging.js";
import { DEFAULT_AGENT_ID, toAgentStoreSessionKey } from "../routing/session-key.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
@@ -32,32 +31,6 @@ let previousHome: string | undefined;
let tempHome: string | undefined;
let tempConfigRoot: string | undefined;
export async function writeSessionStore(params: {
entries: Record<string, Partial<SessionEntry>>;
storePath?: string;
agentId?: string;
mainKey?: string;
}): Promise<void> {
const storePath = params.storePath ?? testState.sessionStorePath;
if (!storePath) throw new Error("writeSessionStore requires testState.sessionStorePath");
const agentId = params.agentId ?? DEFAULT_AGENT_ID;
const store: Record<string, Partial<SessionEntry>> = {};
for (const [requestKey, entry] of Object.entries(params.entries)) {
const rawKey = requestKey.trim();
const storeKey =
rawKey === "global" || rawKey === "unknown"
? rawKey
: toAgentStoreSessionKey({
agentId,
requestKey,
mainKey: params.mainKey,
});
store[storeKey] = entry;
}
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
}
export function installGatewayTestHooks() {
beforeEach(async () => {
setLoggerOverride({ level: "silent", consoleLevel: "silent" });
+3 -2
View File
@@ -97,8 +97,9 @@ describe("hooks install (e2e)", () => {
expect(installResult.ok).toBe(true);
if (!installResult.ok) return;
const { clearInternalHooks, createInternalHookEvent, triggerInternalHook } =
await import("./internal-hooks.js");
const { clearInternalHooks, createInternalHookEvent, triggerInternalHook } = await import(
"./internal-hooks.js"
);
const { loadInternalHooks } = await import("./loader.js");
clearInternalHooks();
+6 -4
View File
@@ -62,7 +62,7 @@ describe("installHooksFromArchive", () => {
"---",
"name: zip-hook",
"description: Zip hook",
'metadata: {"clawdbot":{"events":["command:new"]}}',
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
"---",
"",
"# Zip Hook",
@@ -82,7 +82,9 @@ describe("installHooksFromArchive", () => {
expect(result.hookPackId).toBe("zip-hooks");
expect(result.hooks).toContain("zip-hook");
expect(result.targetDir).toBe(path.join(stateDir, "hooks", "zip-hooks"));
expect(fs.existsSync(path.join(result.targetDir, "hooks", "zip-hook", "HOOK.md"))).toBe(true);
expect(
fs.existsSync(path.join(result.targetDir, "hooks", "zip-hook", "HOOK.md")),
).toBe(true);
});
it("installs hook packs from tar archives", async () => {
@@ -107,7 +109,7 @@ describe("installHooksFromArchive", () => {
"---",
"name: tar-hook",
"description: Tar hook",
'metadata: {"clawdbot":{"events":["command:new"]}}',
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
"---",
"",
"# Tar Hook",
@@ -146,7 +148,7 @@ describe("installHooksFromPath", () => {
"---",
"name: my-hook",
"description: My hook",
'metadata: {"clawdbot":{"events":["command:new"]}}',
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
"---",
"",
"# My Hook",
+2 -6
View File
@@ -134,9 +134,7 @@ async function installHookPackageFromDir(params: {
};
}
const hooksDir = params.hooksDir
? resolveUserPath(params.hooksDir)
: path.join(CONFIG_DIR, "hooks");
const hooksDir = params.hooksDir ? resolveUserPath(params.hooksDir) : path.join(CONFIG_DIR, "hooks");
await fs.mkdir(hooksDir, { recursive: true });
const targetDir = resolveHookInstallDir(hookPackId, hooksDir);
@@ -234,9 +232,7 @@ async function installHookFromDir(params: {
};
}
const hooksDir = params.hooksDir
? resolveUserPath(params.hooksDir)
: path.join(CONFIG_DIR, "hooks");
const hooksDir = params.hooksDir ? resolveUserPath(params.hooksDir) : path.join(CONFIG_DIR, "hooks");
await fs.mkdir(hooksDir, { recursive: true });
const targetDir = resolveHookInstallDir(hookName, hooksDir);
+6 -2
View File
@@ -79,7 +79,9 @@ export async function loadInternalHooks(
// Register for all events listed in metadata
const events = entry.clawdbot?.events ?? [];
if (events.length === 0) {
console.warn(`Hook warning: Hook '${entry.hook.name}' has no events defined in metadata`);
console.warn(
`Hook warning: Hook '${entry.hook.name}' has no events defined in metadata`,
);
continue;
}
@@ -124,7 +126,9 @@ export async function loadInternalHooks(
const handler = mod[exportName];
if (typeof handler !== "function") {
console.error(`Hook error: Handler '${exportName}' from ${modulePath} is not a function`);
console.error(
`Hook error: Handler '${exportName}' from ${modulePath} is not a function`,
);
continue;
}
+1 -5
View File
@@ -48,11 +48,7 @@ function resolvePackageHooks(manifest: HookPackageManifest): string[] {
return raw.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}
function loadHookFromDir(params: {
hookDir: string;
source: string;
nameHint?: string;
}): Hook | null {
function loadHookFromDir(params: { hookDir: string; source: string; nameHint?: string }): Hook | null {
const hookMdPath = path.join(params.hookDir, "HOOK.md");
if (!fs.existsSync(hookMdPath)) return null;
+4 -1
View File
@@ -61,7 +61,10 @@ export async function withTimeout<T>(
}
}
async function extractZip(params: { archivePath: string; destDir: string }): Promise<void> {
async function extractZip(params: {
archivePath: string;
destDir: string;
}): Promise<void> {
const buffer = await fs.readFile(params.archivePath);
const zip = await JSZip.loadAsync(buffer);
const entries = Object.values(zip.files);
+1 -5
View File
@@ -9,11 +9,7 @@ import {
normalizeMessageChannel,
type GatewayMessageChannel,
} from "../../utils/message-channel.js";
import {
resolveOutboundTarget,
resolveSessionDeliveryTarget,
type SessionDeliveryTarget,
} from "./targets.js";
import { resolveOutboundTarget, resolveSessionDeliveryTarget, type SessionDeliveryTarget } from "./targets.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { OutboundTargetResolution } from "./targets.js";
+3 -1
View File
@@ -121,7 +121,9 @@ describe("applyMediaUnderstanding", () => {
expect(result.appliedAudio).toBe(true);
expect(ctx.Transcript).toBe("transcribed text");
expect(ctx.Body).toBe("[Audio]\nUser text:\n/capture status\nTranscript:\ntranscribed text");
expect(ctx.Body).toBe(
"[Audio]\nUser text:\n/capture status\nTranscript:\ntranscribed text",
);
expect(ctx.CommandBody).toBe("/capture status");
expect(ctx.RawBody).toBe("/capture status");
expect(ctx.BodyForCommands).toBe("/capture status");
+4 -1
View File
@@ -78,7 +78,10 @@ export async function applyMediaUnderstanding(params: {
}
if (decisions.length > 0) {
ctx.MediaUnderstandingDecisions = [...(ctx.MediaUnderstandingDecisions ?? []), ...decisions];
ctx.MediaUnderstandingDecisions = [
...(ctx.MediaUnderstandingDecisions ?? []),
...decisions,
];
}
if (outputs.length > 0) {
+1 -1
View File
@@ -104,7 +104,7 @@ export function resolveAttachmentKind(
attachment: MediaAttachment,
): "image" | "audio" | "video" | "document" | "unknown" {
const kind = kindFromMime(attachment.mime);
if (kind === "image" || kind === "audio" || kind === "video") return kind;
if (kind !== "unknown") return kind;
const ext = getFileExtension(attachment.path ?? attachment.url);
if (!ext) return "unknown";
+4 -1
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveEntriesWithActiveFallback, resolveModelEntries } from "./resolve.js";
import {
resolveEntriesWithActiveFallback,
resolveModelEntries,
} from "./resolve.js";
const providerRegistry = new Map([
["openai", { capabilities: ["image"] }],
+2 -7
View File
@@ -100,9 +100,7 @@ function buildModelDecision(params: {
function formatDecisionSummary(decision: MediaUnderstandingDecision): string {
const total = decision.attachments.length;
const success = decision.attachments.filter(
(entry) => entry.chosen?.outcome === "success",
).length;
const success = decision.attachments.filter((entry) => entry.chosen?.outcome === "success").length;
const chosen = decision.attachments.find((entry) => entry.chosen)?.chosen;
const provider = chosen?.provider?.trim();
const model = chosen?.model?.trim();
@@ -357,10 +355,7 @@ async function runAttachmentEntries(params: {
cache: MediaAttachmentCache;
entries: MediaUnderstandingModelConfig[];
config?: MediaUnderstandingConfig;
}): Promise<{
output: MediaUnderstandingOutput | null;
attempts: MediaUnderstandingModelDecision[];
}> {
}): Promise<{ output: MediaUnderstandingOutput | null; attempts: MediaUnderstandingModelDecision[] }> {
const { entries, capability } = params;
const attempts: MediaUnderstandingModelDecision[] = [];
for (const entry of entries) {
@@ -1,92 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
vi.mock("chokidar", () => ({
default: {
watch: vi.fn(() => ({
on: vi.fn(),
close: vi.fn(async () => undefined),
})),
},
}));
vi.mock("./embeddings.js", () => {
return {
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {
id: "mock",
model: "mock-embed",
embedQuery: async () => [0, 0, 0],
embedBatch: async () => {
throw new Error("openai embeddings failed: 429 insufficient_quota");
},
},
}),
};
});
describe("memory manager sync failures", () => {
let workspaceDir: string;
let indexPath: string;
let manager: MemoryIndexManager | null = null;
beforeEach(async () => {
vi.useFakeTimers();
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-mem-"));
indexPath = path.join(workspaceDir, "index.sqlite");
await fs.mkdir(path.join(workspaceDir, "memory"));
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello");
});
afterEach(async () => {
vi.useRealTimers();
if (manager) {
await manager.close();
manager = null;
}
await fs.rm(workspaceDir, { recursive: true, force: true });
});
it("does not raise unhandledRejection when watch-triggered sync fails", async () => {
const unhandled: unknown[] = [];
const handler = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", handler);
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexPath },
sync: { watch: true, watchDebounceMs: 1, onSessionStart: false, onSearch: false },
},
},
list: [{ id: "main", default: true }],
},
};
const result = await getMemorySearchManager({ cfg, agentId: "main" });
expect(result.manager).not.toBeNull();
if (!result.manager) throw new Error("manager missing");
manager = result.manager;
// Call the internal scheduler directly; it uses fire-and-forget sync.
(manager as unknown as { scheduleWatchSync: () => void }).scheduleWatchSync();
await vi.runAllTimersAsync();
await Promise.resolve();
process.off("unhandledRejection", handler);
expect(unhandled).toHaveLength(0);
});
});
+2 -9
View File
@@ -8,7 +8,6 @@ import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope
import type { ResolvedMemorySearchConfig } from "../agents/memory-search.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import type { ClawdbotConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath, truncateUtf16Safe } from "../utils.js";
import {
createEmbeddingProvider,
@@ -47,8 +46,6 @@ type MemoryIndexMeta = {
const META_KEY = "memory_index_meta_v1";
const SNIPPET_MAX_CHARS = 700;
const log = createSubsystemLogger("memory");
const INDEX_CACHE = new Map<string, MemoryIndexManager>();
export class MemoryIndexManager {
@@ -317,9 +314,7 @@ export class MemoryIndexManager {
if (!minutes || minutes <= 0 || this.intervalTimer) return;
const ms = minutes * 60 * 1000;
this.intervalTimer = setInterval(() => {
void this.sync({ reason: "interval" }).catch((err) => {
log.warn(`memory sync failed (interval): ${String(err)}`);
});
void this.sync({ reason: "interval" });
}, ms);
}
@@ -328,9 +323,7 @@ export class MemoryIndexManager {
if (this.watchTimer) clearTimeout(this.watchTimer);
this.watchTimer = setTimeout(() => {
this.watchTimer = null;
void this.sync({ reason: "watch" }).catch((err) => {
log.warn(`memory sync failed (watch): ${String(err)}`);
});
void this.sync({ reason: "watch" });
}, this.settings.sync.watchDebounceMs);
}
-19
View File
@@ -16,25 +16,6 @@ export type ParsedAgentSessionKey = {
rest: string;
};
export function toAgentRequestSessionKey(storeKey: string | undefined | null): string | undefined {
const raw = (storeKey ?? "").trim();
if (!raw) return undefined;
return parseAgentSessionKey(raw)?.rest ?? raw;
}
export function toAgentStoreSessionKey(params: {
agentId: string;
requestKey: string | undefined | null;
mainKey?: string | undefined;
}): string {
const raw = (params.requestKey ?? "").trim();
if (!raw || raw === DEFAULT_MAIN_KEY) {
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey });
}
if (raw.startsWith("agent:")) return raw;
return `agent:${normalizeAgentId(params.agentId)}:${raw}`;
}
export function resolveAgentIdFromSessionKey(sessionKey: string | undefined | null): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
+10 -14
View File
@@ -474,8 +474,10 @@ async function collectChannelSecurityFindings(params: {
if (plugin.id === "discord") {
const discordCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
(account as { config?: Record<string, unknown> } | null)?.config ?? ({} as Record<
string,
unknown
>);
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "discord",
providerSetting: coerceNativeSetting(
@@ -514,20 +516,15 @@ async function collectChannelSecurityFindings(params: {
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (
!useAccessGroups &&
groupPolicy !== "disabled" &&
guildsConfigured &&
!hasAnyUserAllowlist
) {
if (!useAccessGroups && groupPolicy !== "disabled" && guildsConfigured && !hasAnyUserAllowlist) {
findings.push({
checkId: "channels.discord.commands.native.unrestricted",
severity: "critical",
title: "Discord slash commands are unrestricted",
detail:
"commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.",
'commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.',
remediation:
"Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).",
'Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).',
});
} else if (
useAccessGroups &&
@@ -543,7 +540,7 @@ async function collectChannelSecurityFindings(params: {
detail:
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Add your user id to channels.discord.dm.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
'Add your user id to channels.discord.dm.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.',
});
}
}
@@ -570,7 +567,7 @@ async function collectChannelSecurityFindings(params: {
const slashCommandEnabled =
nativeEnabled ||
nativeSkillsEnabled ||
(slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true;
((slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true);
if (slashCommandEnabled) {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
@@ -583,8 +580,7 @@ async function collectChannelSecurityFindings(params: {
remediation: "Set commands.useAccessGroups=true (recommended).",
});
} else {
const dmAllowFromRaw = (account as { dm?: { allowFrom?: unknown } } | null)?.dm
?.allowFrom;
const dmAllowFromRaw = (account as { dm?: { allowFrom?: unknown } } | null)?.dm?.allowFrom;
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(() => []);
const ownerAllowFromConfigured =
+6 -6
View File
@@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest";
import {
buildSlackSlashCommandMatcher,
isSlackChannelAllowedByPolicy,
isSlackRoomAllowedByPolicy,
resolveSlackThreadTs,
} from "./monitor.js";
describe("slack groupPolicy gating", () => {
it("allows when policy is open", () => {
expect(
isSlackChannelAllowedByPolicy({
isSlackRoomAllowedByPolicy({
groupPolicy: "open",
channelAllowlistConfigured: false,
channelAllowed: false,
@@ -19,7 +19,7 @@ describe("slack groupPolicy gating", () => {
it("blocks when policy is disabled", () => {
expect(
isSlackChannelAllowedByPolicy({
isSlackRoomAllowedByPolicy({
groupPolicy: "disabled",
channelAllowlistConfigured: true,
channelAllowed: true,
@@ -29,7 +29,7 @@ describe("slack groupPolicy gating", () => {
it("blocks allowlist when no channel allowlist configured", () => {
expect(
isSlackChannelAllowedByPolicy({
isSlackRoomAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: false,
channelAllowed: true,
@@ -39,7 +39,7 @@ describe("slack groupPolicy gating", () => {
it("allows allowlist when channel is allowed", () => {
expect(
isSlackChannelAllowedByPolicy({
isSlackRoomAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: true,
channelAllowed: true,
@@ -49,7 +49,7 @@ describe("slack groupPolicy gating", () => {
it("blocks allowlist when channel is not allowed", () => {
expect(
isSlackChannelAllowedByPolicy({
isSlackRoomAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: true,
channelAllowed: false,
+1 -1
View File
@@ -1,5 +1,5 @@
export { buildSlackSlashCommandMatcher } from "./monitor/commands.js";
export { isSlackChannelAllowedByPolicy } from "./monitor/policy.js";
export { isSlackRoomAllowedByPolicy } from "./monitor/policy.js";
export { monitorSlackProvider } from "./monitor/provider.js";
export { resolveSlackThreadTs } from "./monitor/replies.js";
export type { MonitorSlackOpts } from "./monitor/types.js";
+2 -2
View File
@@ -11,7 +11,7 @@ import type { SlackMessageEvent } from "../types.js";
import { normalizeAllowList, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js";
import { resolveSlackChannelConfig } from "./channel-config.js";
import { isSlackChannelAllowedByPolicy } from "./policy.js";
import { isSlackRoomAllowedByPolicy } from "./policy.js";
export function inferSlackChannelType(
channelId?: string | null,
@@ -314,7 +314,7 @@ export function createSlackMonitorContext(params: {
const channelAllowlistConfigured =
Boolean(params.channelsConfig) && Object.keys(params.channelsConfig ?? {}).length > 0;
if (
!isSlackChannelAllowedByPolicy({
!isSlackRoomAllowedByPolicy({
groupPolicy: params.groupPolicy,
channelAllowlistConfigured,
channelAllowed,
+1 -1
View File
@@ -1,4 +1,4 @@
export function isSlackChannelAllowedByPolicy(params: {
export function isSlackRoomAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";
channelAllowlistConfigured: boolean;
channelAllowed: boolean;
+2 -2
View File
@@ -33,7 +33,7 @@ import {
import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./channel-config.js";
import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js";
import type { SlackMonitorContext } from "./context.js";
import { isSlackChannelAllowedByPolicy } from "./policy.js";
import { isSlackRoomAllowedByPolicy } from "./policy.js";
import { deliverSlackSlashReplies } from "./replies.js";
type SlackBlock = { type: string; [key: string]: unknown };
@@ -247,7 +247,7 @@ export function registerSlackMonitorSlashCommands(params: {
Boolean(ctx.channelsConfig) && Object.keys(ctx.channelsConfig ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
!isSlackChannelAllowedByPolicy({
!isSlackRoomAllowedByPolicy({
groupPolicy: ctx.groupPolicy,
channelAllowlistConfigured,
channelAllowed,
+2 -2
View File
@@ -110,8 +110,8 @@ export const registerTelegramNativeCommands = ({
if (allCommands.length > 0) {
bot.api.setMyCommands(allCommands).catch((err) => {
runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`));
});
runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`));
});
if (typeof (bot as unknown as { command?: unknown }).command !== "function") {
logVerbose("telegram: bot.command unavailable; skipping native handlers");
-39
View File
@@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveTelegramTargetChatType } from "./inline-buttons.js";
describe("resolveTelegramTargetChatType", () => {
it("returns 'direct' for positive numeric IDs", () => {
expect(resolveTelegramTargetChatType("5232990709")).toBe("direct");
expect(resolveTelegramTargetChatType("123456789")).toBe("direct");
});
it("returns 'group' for negative numeric IDs", () => {
expect(resolveTelegramTargetChatType("-123456789")).toBe("group");
expect(resolveTelegramTargetChatType("-1001234567890")).toBe("group");
});
it("handles telegram: prefix from normalizeTelegramMessagingTarget", () => {
expect(resolveTelegramTargetChatType("telegram:5232990709")).toBe("direct");
expect(resolveTelegramTargetChatType("telegram:-123456789")).toBe("group");
expect(resolveTelegramTargetChatType("TELEGRAM:5232990709")).toBe("direct");
});
it("handles tg/group prefixes and topic suffixes", () => {
expect(resolveTelegramTargetChatType("tg:5232990709")).toBe("direct");
expect(resolveTelegramTargetChatType("group:-123456789")).toBe("group");
expect(resolveTelegramTargetChatType("telegram:group:-1001234567890")).toBe("group");
expect(resolveTelegramTargetChatType("telegram:group:-1001234567890:topic:456")).toBe("group");
expect(resolveTelegramTargetChatType("-1001234567890:456")).toBe("group");
});
it("returns 'unknown' for usernames", () => {
expect(resolveTelegramTargetChatType("@username")).toBe("unknown");
expect(resolveTelegramTargetChatType("telegram:@username")).toBe("unknown");
});
it("returns 'unknown' for empty strings", () => {
expect(resolveTelegramTargetChatType("")).toBe("unknown");
expect(resolveTelegramTargetChatType(" ")).toBe("unknown");
});
});
+4 -7
View File
@@ -1,7 +1,6 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { TelegramInlineButtonsScope } from "../config/types.telegram.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
import { parseTelegramTarget } from "./targets.js";
const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist";
@@ -62,12 +61,10 @@ export function isTelegramInlineButtonsEnabled(params: {
}
export function resolveTelegramTargetChatType(target: string): "direct" | "group" | "unknown" {
if (!target.trim()) return "unknown";
const parsed = parseTelegramTarget(target);
const chatId = parsed.chatId.trim();
if (!chatId) return "unknown";
if (/^-?\d+$/.test(chatId)) {
return chatId.startsWith("-") ? "group" : "direct";
const trimmed = target.trim();
if (!trimmed) return "unknown";
if (/^-?\d+$/.test(trimmed)) {
return trimmed.startsWith("-") ? "group" : "direct";
}
return "unknown";
}
+1 -6
View File
@@ -8,11 +8,10 @@ type EventHandlerContext = {
tui: TUI;
state: TuiStateAccess;
setActivityStatus: (text: string) => void;
refreshSessionInfo?: () => Promise<void>;
};
export function createEventHandlers(context: EventHandlerContext) {
const { chatLog, tui, state, setActivityStatus, refreshSessionInfo } = context;
const { chatLog, tui, state, setActivityStatus } = context;
const finalizedRuns = new Map<string, number>();
const noteFinalizedRun = (runId: string) => {
@@ -65,20 +64,16 @@ export function createEventHandlers(context: EventHandlerContext) {
noteFinalizedRun(evt.runId);
state.activeChatRunId = null;
setActivityStatus(stopReason === "error" ? "error" : "idle");
// Refresh session info to update token counts in footer
void refreshSessionInfo?.();
}
if (evt.state === "aborted") {
chatLog.addSystem("run aborted");
state.activeChatRunId = null;
setActivityStatus("aborted");
void refreshSessionInfo?.();
}
if (evt.state === "error") {
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
state.activeChatRunId = null;
setActivityStatus("error");
void refreshSessionInfo?.();
}
tui.requestRender();
};
+35 -44
View File
@@ -41,7 +41,6 @@ export function createSessionActions(context: SessionActionContext) {
updateAutocompleteProvider,
setActivityStatus,
} = context;
let refreshSessionInfoPromise: Promise<void> | null = null;
const applyAgentsResult = (result: GatewayAgentsList) => {
state.agentDefaultId = normalizeAgentId(result.defaultId);
@@ -96,51 +95,43 @@ export function createSessionActions(context: SessionActionContext) {
};
const refreshSessionInfo = async () => {
if (refreshSessionInfoPromise) return refreshSessionInfoPromise;
refreshSessionInfoPromise = (async () => {
try {
const listAgentId =
state.currentSessionKey === "global" || state.currentSessionKey === "unknown"
? undefined
: state.currentAgentId;
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
agentId: listAgentId,
});
const entry = result.sessions.find((row) => {
// Exact match
if (row.key === state.currentSessionKey) return true;
// Also match canonical keys like "agent:default:main" against "main"
const parsed = parseAgentSessionKey(row.key);
return parsed?.rest === state.currentSessionKey;
});
state.sessionInfo = {
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
model: entry?.model ?? result.defaults?.model ?? undefined,
modelProvider: entry?.modelProvider ?? result.defaults?.modelProvider ?? undefined,
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
inputTokens: entry?.inputTokens ?? null,
outputTokens: entry?.outputTokens ?? null,
totalTokens: entry?.totalTokens ?? null,
responseUsage: entry?.responseUsage,
updatedAt: entry?.updatedAt ?? null,
displayName: entry?.displayName,
};
} catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`);
}
updateAutocompleteProvider();
updateFooter();
tui.requestRender();
})();
try {
await refreshSessionInfoPromise;
} finally {
refreshSessionInfoPromise = null;
const listAgentId =
state.currentSessionKey === "global" || state.currentSessionKey === "unknown"
? undefined
: state.currentAgentId;
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
agentId: listAgentId,
});
const entry = result.sessions.find((row) => {
// Exact match
if (row.key === state.currentSessionKey) return true;
// Also match canonical keys like "agent:default:main" against "main"
const parsed = parseAgentSessionKey(row.key);
return parsed?.rest === state.currentSessionKey;
});
state.sessionInfo = {
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
model: entry?.model ?? result.defaults?.model ?? undefined,
modelProvider: entry?.modelProvider ?? result.defaults?.modelProvider ?? undefined,
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
inputTokens: entry?.inputTokens ?? null,
outputTokens: entry?.outputTokens ?? null,
totalTokens: entry?.totalTokens ?? null,
responseUsage: entry?.responseUsage,
updatedAt: entry?.updatedAt ?? null,
displayName: entry?.displayName,
};
} catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`);
}
updateAutocompleteProvider();
updateFooter();
tui.requestRender();
};
const loadHistory = async () => {
-1
View File
@@ -399,7 +399,6 @@ export async function runTui(opts: TuiOptions) {
tui,
state,
setActivityStatus,
refreshSessionInfo,
});
const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } =
+4 -2
View File
@@ -19,7 +19,7 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon
if (!context) return undefined;
const channel =
typeof context.channel === "string"
? (normalizeMessageChannel(context.channel) ?? context.channel.trim())
? normalizeMessageChannel(context.channel) ?? context.channel.trim()
: undefined;
const to = typeof context.to === "string" ? context.to.trim() : undefined;
const accountId = normalizeAccountId(context.accountId);
@@ -31,7 +31,9 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon
};
}
export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSource): {
export function normalizeSessionDeliveryFields(
source?: DeliveryContextSessionSource,
): {
deliveryContext?: DeliveryContext;
lastChannel?: string;
lastTo?: string;
@@ -78,15 +78,9 @@ async function resolveWhatsAppCommandAuthorized(params: {
}
const storeAllowFrom = await readChannelAllowFromStore("whatsapp").catch(() => []);
const combinedAllowFrom = Array.from(
new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]),
);
const combinedAllowFrom = Array.from(new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]));
const allowFrom =
combinedAllowFrom.length > 0
? combinedAllowFrom
: params.msg.selfE164
? [params.msg.selfE164]
: [];
combinedAllowFrom.length > 0 ? combinedAllowFrom : params.msg.selfE164 ? [params.msg.selfE164] : [];
if (allowFrom.some((v) => String(v).trim() === "*")) return true;
return normalizeAllowFromE164(allowFrom).includes(senderE164);
}