chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-14 14:31:43 +00:00
parent 912ebffc63
commit c379191f80
1480 changed files with 28608 additions and 43547 deletions

View File

@@ -13,9 +13,7 @@ export async function readLatestAssistantReply(params: {
method: "chat.history",
params: { sessionKey: params.sessionKey, limit: params.limit ?? 50 },
})) as { messages?: unknown[] };
const filtered = stripToolMessages(
Array.isArray(history?.messages) ? history.messages : [],
);
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
return last ? extractAssistantText(last) : undefined;
}
@@ -43,8 +41,7 @@ export async function runAgentStep(params: {
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const stepRunId =
typeof response?.runId === "string" && response.runId ? response.runId : "";
const stepRunId = typeof response?.runId === "string" && response.runId ? response.runId : "";
const resolvedRunId = stepRunId || stepIdem;
const stepWaitMs = Math.min(params.timeoutMs, 60_000);
const wait = (await callGateway({

View File

@@ -9,10 +9,7 @@ import {
import { resolveAgentConfig } from "../agent-scope.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult } from "./common.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "./sessions-helpers.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
const AgentsListToolSchema = Type.Object({});
@@ -22,14 +19,11 @@ type AgentListEntry = {
configured: boolean;
};
export function createAgentsListTool(opts?: {
agentSessionKey?: string;
}): AnyAgentTool {
export function createAgentsListTool(opts?: { agentSessionKey?: string }): AnyAgentTool {
return {
label: "Agents",
name: "agents_list",
description:
"List agent ids you can target with sessions_spawn (based on allowlists).",
description: "List agent ids you can target with sessions_spawn (based on allowlists).",
parameters: AgentsListToolSchema,
execute: async () => {
const cfg = loadConfig();
@@ -46,8 +40,7 @@ export function createAgentsListTool(opts?: {
parseAgentSessionKey(requesterInternalKey)?.agentId ?? DEFAULT_AGENT_ID,
);
const allowAgents =
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? [];
const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? [];
const allowAny = allowAgents.some((value) => value.trim() === "*");
const allowSet = new Set(
allowAgents
@@ -55,12 +48,8 @@ export function createAgentsListTool(opts?: {
.map((value) => normalizeAgentId(value)),
);
const configuredAgents = Array.isArray(cfg.agents?.list)
? cfg.agents?.list
: [];
const configuredIds = configuredAgents.map((entry) =>
normalizeAgentId(entry.id),
);
const configuredAgents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
const configuredIds = configuredAgents.map((entry) => normalizeAgentId(entry.id));
const configuredNameMap = new Map<string, string>();
for (const entry of configuredAgents) {
const name = entry?.name?.trim() ?? "";
@@ -77,9 +66,7 @@ export function createAgentsListTool(opts?: {
}
const all = Array.from(allowed);
const rest = all
.filter((id) => id !== requesterAgentId)
.sort((a, b) => a.localeCompare(b));
const rest = all.filter((id) => id !== requesterAgentId).sort((a, b) => a.localeCompare(b));
const ordered = [requesterAgentId, ...rest];
const agents: AgentListEntry[] = ordered.map((id) => ({
id,

View File

@@ -64,9 +64,7 @@ const BrowserActSchema = Type.Object({
// select
values: Type.Optional(Type.Array(Type.String())),
// fill - use permissive array of objects
fields: Type.Optional(
Type.Array(Type.Object({}, { additionalProperties: true })),
),
fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))),
// resize
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),

View File

@@ -21,12 +21,7 @@ import { resolveBrowserConfig } from "../../browser/config.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { loadConfig } from "../../config/config.js";
import { BrowserToolSchema } from "./browser-tool.schema.js";
import {
type AnyAgentTool,
imageResultFromFile,
jsonResult,
readStringParam,
} from "./common.js";
import { type AnyAgentTool, imageResultFromFile, jsonResult, readStringParam } from "./common.js";
function resolveBrowserBaseUrl(params: {
target?: "sandbox" | "host" | "custom";
@@ -42,22 +37,13 @@ function resolveBrowserBaseUrl(params: {
const normalizedControlUrl = params.controlUrl?.trim() ?? "";
const normalizedDefault = params.defaultControlUrl?.trim() ?? "";
const target =
params.target ??
(normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host");
params.target ?? (normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host");
const assertAllowedControlUrl = (url: string) => {
const allowedUrls = params.allowedControlUrls?.map((entry) =>
entry.trim().replace(/\/$/, ""),
);
const allowedHosts = params.allowedControlHosts?.map((entry) =>
entry.trim().toLowerCase(),
);
const allowedUrls = params.allowedControlUrls?.map((entry) => entry.trim().replace(/\/$/, ""));
const allowedHosts = params.allowedControlHosts?.map((entry) => entry.trim().toLowerCase());
const allowedPorts = params.allowedControlPorts;
if (
!allowedUrls?.length &&
!allowedHosts?.length &&
!allowedPorts?.length
) {
if (!allowedUrls?.length && !allowedHosts?.length && !allowedPorts?.length) {
return;
}
let parsed: URL;
@@ -71,21 +57,13 @@ function resolveBrowserBaseUrl(params: {
throw new Error("Browser controlUrl is not in the allowed URL list.");
}
if (allowedHosts?.length && !allowedHosts.includes(parsed.hostname)) {
throw new Error(
"Browser controlUrl hostname is not in the allowed host list.",
);
throw new Error("Browser controlUrl hostname is not in the allowed host list.");
}
if (allowedPorts?.length) {
const port =
parsed.port?.trim() !== ""
? Number(parsed.port)
: parsed.protocol === "https:"
? 443
: 80;
parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
if (!Number.isFinite(port) || !allowedPorts.includes(port)) {
throw new Error(
"Browser controlUrl port is not in the allowed port list.",
);
throw new Error("Browser controlUrl port is not in the allowed port list.");
}
}
};
@@ -134,9 +112,7 @@ export function createBrowserTool(opts?: {
}): AnyAgentTool {
const targetDefault = opts?.defaultControlUrl ? "sandbox" : "host";
const hostHint =
opts?.allowHostControl === false
? "Host target blocked by policy."
: "Host target allowed.";
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
const allowlistHint =
opts?.allowedControlUrls?.length ||
opts?.allowedControlHosts?.length ||
@@ -159,11 +135,7 @@ export function createBrowserTool(opts?: {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const controlUrl = readStringParam(params, "controlUrl");
const target = readStringParam(params, "target") as
| "sandbox"
| "host"
| "custom"
| undefined;
const target = readStringParam(params, "target") as "sandbox" | "host" | "custom" | undefined;
const profile = readStringParam(params, "profile");
const baseUrl = resolveBrowserBaseUrl({
target,
@@ -190,9 +162,7 @@ export function createBrowserTool(opts?: {
const targetUrl = readStringParam(params, "targetUrl", {
required: true,
});
return jsonResult(
await browserOpenTab(baseUrl, targetUrl, { profile }),
);
return jsonResult(await browserOpenTab(baseUrl, targetUrl, { profile }));
}
case "focus": {
const targetId = readStringParam(params, "targetId", {
@@ -213,10 +183,7 @@ export function createBrowserTool(opts?: {
? (params.format as "ai" | "aria")
: "ai";
const hasMaxChars = Object.hasOwn(params, "maxChars");
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
@@ -228,34 +195,21 @@ export function createBrowserTool(opts?: {
? Math.floor(params.maxChars)
: undefined;
const resolvedMaxChars =
format === "ai"
? hasMaxChars
? maxChars
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: undefined;
format === "ai" ? (hasMaxChars ? maxChars : DEFAULT_AI_SNAPSHOT_MAX_CHARS) : undefined;
const interactive =
typeof params.interactive === "boolean"
? params.interactive
: undefined;
const compact =
typeof params.compact === "boolean" ? params.compact : undefined;
typeof params.interactive === "boolean" ? params.interactive : undefined;
const compact = typeof params.compact === "boolean" ? params.compact : undefined;
const depth =
typeof params.depth === "number" && Number.isFinite(params.depth)
? params.depth
: undefined;
const selector =
typeof params.selector === "string"
? params.selector.trim()
: undefined;
const frame =
typeof params.frame === "string" ? params.frame.trim() : undefined;
const selector = typeof params.selector === "string" ? params.selector.trim() : undefined;
const frame = typeof params.frame === "string" ? params.frame.trim() : undefined;
const snapshot = await browserSnapshot(baseUrl, {
format,
targetId,
limit,
...(typeof resolvedMaxChars === "number"
? { maxChars: resolvedMaxChars }
: {}),
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
interactive,
compact,
depth,
@@ -305,21 +259,12 @@ export function createBrowserTool(opts?: {
);
}
case "console": {
const level =
typeof params.level === "string" ? params.level.trim() : undefined;
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
return jsonResult(
await browserConsoleMessages(baseUrl, { level, targetId, profile }),
);
const level = typeof params.level === "string" ? params.level.trim() : undefined;
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
return jsonResult(await browserConsoleMessages(baseUrl, { level, targetId, profile }));
}
case "pdf": {
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
const result = await browserPdfSave(baseUrl, { targetId, profile });
return {
content: [{ type: "text", text: `FILE:${result.path}` }],
@@ -327,20 +272,14 @@ export function createBrowserTool(opts?: {
};
}
case "upload": {
const paths = Array.isArray(params.paths)
? params.paths.map((p) => String(p))
: [];
const paths = Array.isArray(params.paths) ? params.paths.map((p) => String(p)) : [];
if (paths.length === 0) throw new Error("paths required");
const ref = readStringParam(params, "ref");
const inputRef = readStringParam(params, "inputRef");
const element = readStringParam(params, "element");
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
const timeoutMs =
typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
return jsonResult(
@@ -357,17 +296,10 @@ export function createBrowserTool(opts?: {
}
case "dialog": {
const accept = Boolean(params.accept);
const promptText =
typeof params.promptText === "string"
? params.promptText
: undefined;
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const promptText = typeof params.promptText === "string" ? params.promptText : undefined;
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
const timeoutMs =
typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
return jsonResult(
@@ -385,11 +317,9 @@ export function createBrowserTool(opts?: {
if (!request || typeof request !== "object") {
throw new Error("request required");
}
const result = await browserAct(
baseUrl,
request as Parameters<typeof browserAct>[1],
{ profile },
);
const result = await browserAct(baseUrl, request as Parameters<typeof browserAct>[1], {
profile,
});
return jsonResult(result);
}
default:

View File

@@ -3,18 +3,10 @@ import fs from "node:fs/promises";
import { Type } from "@sinclair/typebox";
import { writeBase64ToFile } from "../../cli/nodes-camera.js";
import {
canvasSnapshotTempPath,
parseCanvasSnapshotPayload,
} from "../../cli/nodes-canvas.js";
import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../../cli/nodes-canvas.js";
import { imageMimeFromFormat } from "../../media/mime.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import {
type AnyAgentTool,
imageResult,
jsonResult,
readStringParam,
} from "./common.js";
import { type AnyAgentTool, imageResult, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveNodeId } from "./nodes-utils.js";
@@ -70,8 +62,7 @@ export function createCanvasTool(): AnyAgentTool {
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
const nodeId = await resolveNodeId(
@@ -80,10 +71,7 @@ export function createCanvasTool(): AnyAgentTool {
true,
);
const invoke = async (
command: string,
invokeParams?: Record<string, unknown>,
) =>
const invoke = async (command: string, invokeParams?: Record<string, unknown>) =>
await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command,
@@ -97,8 +85,7 @@ export function createCanvasTool(): AnyAgentTool {
x: typeof params.x === "number" ? params.x : undefined,
y: typeof params.y === "number" ? params.y : undefined,
width: typeof params.width === "number" ? params.width : undefined,
height:
typeof params.height === "number" ? params.height : undefined,
height: typeof params.height === "number" ? params.height : undefined,
};
const invokeParams: Record<string, unknown> = {};
if (typeof params.target === "string" && params.target.trim()) {
@@ -140,20 +127,14 @@ export function createCanvasTool(): AnyAgentTool {
return jsonResult({ ok: true });
}
case "snapshot": {
const formatRaw =
typeof params.format === "string"
? params.format.toLowerCase()
: "png";
const format =
formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png";
const formatRaw = typeof params.format === "string" ? params.format.toLowerCase() : "png";
const format = formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png";
const maxWidth =
typeof params.maxWidth === "number" &&
Number.isFinite(params.maxWidth)
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth)
? params.maxWidth
: undefined;
const quality =
typeof params.quality === "number" &&
Number.isFinite(params.quality)
typeof params.quality === "number" && Number.isFinite(params.quality)
? params.quality
: undefined;
const raw = (await invoke("canvas.snapshot", {

View File

@@ -38,9 +38,9 @@ describe("readStringOrNumberParam", () => {
});
it("throws when required and missing", () => {
expect(() =>
readStringOrNumberParam({}, "chatId", { required: true }),
).toThrow(/chatId required/);
expect(() => readStringOrNumberParam({}, "chatId", { required: true })).toThrow(
/chatId required/,
);
});
});

View File

@@ -45,12 +45,7 @@ export function readStringParam(
key: string,
options: StringParamOptions = {},
) {
const {
required = false,
trim = true,
label = key,
allowEmpty = false,
} = options;
const { required = false, trim = true, label = key, allowEmpty = false } = options;
const raw = params[key];
if (typeof raw !== "string") {
if (required) throw new Error(`${label} required`);
@@ -162,8 +157,7 @@ export function readReactionParams(
): ReactionParams {
const emojiKey = options.emojiKey ?? "emoji";
const removeKey = options.removeKey ?? "remove";
const remove =
typeof params[removeKey] === "boolean" ? params[removeKey] : false;
const remove = typeof params[removeKey] === "boolean" ? params[removeKey] : false;
const emoji = readStringParam(params, emojiKey, {
required: true,
allowEmpty: true,
@@ -219,8 +213,7 @@ export async function imageResultFromFile(params: {
details?: Record<string, unknown>;
}): Promise<AgentToolResult<unknown>> {
const buf = await fs.readFile(params.path);
const mimeType =
(await detectMime({ buffer: buf.slice(0, 256) })) ?? "image/png";
const mimeType = (await detectMime({ buffer: buf.slice(0, 256) })) ?? "image/png";
return await imageResult({
label: params.label,
path: params.path,

View File

@@ -1,8 +1,5 @@
import { Type } from "@sinclair/typebox";
import {
normalizeCronJobCreate,
normalizeCronJobPatch,
} from "../../cron/normalize.js";
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
@@ -12,16 +9,7 @@ import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
// contain nested unions. Tool schemas need to stay provider-friendly, so we
// accept "any object" here and validate at runtime.
const CRON_ACTIONS = [
"status",
"list",
"add",
"update",
"remove",
"run",
"runs",
"wake",
] as const;
const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs", "wake"] as const;
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
@@ -53,15 +41,12 @@ export function createCronTool(): AnyAgentTool {
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
switch (action) {
case "status":
return jsonResult(
await callGatewayTool("cron.status", gatewayOpts, {}),
);
return jsonResult(await callGatewayTool("cron.status", gatewayOpts, {}));
case "list":
return jsonResult(
await callGatewayTool("cron.list", gatewayOpts, {
@@ -73,17 +58,12 @@ export function createCronTool(): AnyAgentTool {
throw new Error("job required");
}
const job = normalizeCronJobCreate(params.job) ?? params.job;
return jsonResult(
await callGatewayTool("cron.add", gatewayOpts, job),
);
return jsonResult(await callGatewayTool("cron.add", gatewayOpts, job));
}
case "update": {
const id =
readStringParam(params, "jobId") ?? readStringParam(params, "id");
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
if (!id) {
throw new Error(
"jobId required (id accepted for backward compatibility)",
);
throw new Error("jobId required (id accepted for backward compatibility)");
}
if (!params.patch || typeof params.patch !== "object") {
throw new Error("patch required");
@@ -97,40 +77,25 @@ export function createCronTool(): AnyAgentTool {
);
}
case "remove": {
const id =
readStringParam(params, "jobId") ?? readStringParam(params, "id");
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
if (!id) {
throw new Error(
"jobId required (id accepted for backward compatibility)",
);
throw new Error("jobId required (id accepted for backward compatibility)");
}
return jsonResult(
await callGatewayTool("cron.remove", gatewayOpts, { id }),
);
return jsonResult(await callGatewayTool("cron.remove", gatewayOpts, { id }));
}
case "run": {
const id =
readStringParam(params, "jobId") ?? readStringParam(params, "id");
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
if (!id) {
throw new Error(
"jobId required (id accepted for backward compatibility)",
);
throw new Error("jobId required (id accepted for backward compatibility)");
}
return jsonResult(
await callGatewayTool("cron.run", gatewayOpts, { id }),
);
return jsonResult(await callGatewayTool("cron.run", gatewayOpts, { id }));
}
case "runs": {
const id =
readStringParam(params, "jobId") ?? readStringParam(params, "id");
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
if (!id) {
throw new Error(
"jobId required (id accepted for backward compatibility)",
);
throw new Error("jobId required (id accepted for backward compatibility)");
}
return jsonResult(
await callGatewayTool("cron.runs", gatewayOpts, { id }),
);
return jsonResult(await callGatewayTool("cron.runs", gatewayOpts, { id }));
}
case "wake": {
const text = readStringParam(params, "text", { required: true });
@@ -139,12 +104,7 @@ export function createCronTool(): AnyAgentTool {
? params.mode
: "next-heartbeat";
return jsonResult(
await callGatewayTool(
"wake",
gatewayOpts,
{ mode, text },
{ expectFinal: false },
),
await callGatewayTool("wake", gatewayOpts, { mode, text }, { expectFinal: false }),
);
}
default:

View File

@@ -28,9 +28,7 @@ import {
readStringParam,
} from "./common.js";
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
function readParentIdParam(params: Record<string, unknown>): string | null | undefined {
if (params.clearParent === true) return null;
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
@@ -206,8 +204,7 @@ export async function handleDiscordGuildAction(
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const entityTypeRaw = readStringParam(params, "entityType");
const entityType =
entityTypeRaw === "stage" ? 1 : entityTypeRaw === "external" ? 3 : 2;
const entityType = entityTypeRaw === "stage" ? 1 : entityTypeRaw === "external" ? 3 : 2;
const payload = {
name,
description,
@@ -215,8 +212,7 @@ export async function handleDiscordGuildAction(
scheduled_end_time: endTime,
entity_type: entityType,
channel_id: channelId,
entity_metadata:
entityType === 3 && location ? { location } : undefined,
entity_metadata: entityType === 3 && location ? { location } : undefined,
privacy_level: 2,
};
const event = await createScheduledEventDiscord(guildId, payload);

View File

@@ -113,9 +113,7 @@ export async function handleDiscordMessagingAction(
});
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw)
? limitRaw
: undefined;
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const reactions = await fetchReactionsDiscord(channelId, messageId, {
limit,
});
@@ -149,14 +147,10 @@ export async function handleDiscordMessagingAction(
});
const allowMultiselectRaw = params.allowMultiselect;
const allowMultiselect =
typeof allowMultiselectRaw === "boolean"
? allowMultiselectRaw
: undefined;
typeof allowMultiselectRaw === "boolean" ? allowMultiselectRaw : undefined;
const durationRaw = params.durationHours;
const durationHours =
typeof durationRaw === "number" && Number.isFinite(durationRaw)
? durationRaw
: undefined;
typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined;
const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1;
await sendPollDiscord(
to,
@@ -215,8 +209,7 @@ export async function handleDiscordMessagingAction(
});
const formattedMessages = messages.map((message) => ({
...message,
timestamp:
formatDiscordTimestamp(message.timestamp) ?? message.timestamp,
timestamp: formatDiscordTimestamp(message.timestamp) ?? message.timestamp,
}));
return jsonResult({ ok: true, messages: formattedMessages });
}
@@ -278,8 +271,7 @@ export async function handleDiscordMessagingAction(
const messageId = readStringParam(params, "messageId");
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
const autoArchiveMinutes =
typeof autoArchiveMinutesRaw === "number" &&
Number.isFinite(autoArchiveMinutesRaw)
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
? autoArchiveMinutesRaw
: undefined;
const thread = await createThreadDiscord(channelId, {
@@ -298,9 +290,7 @@ export async function handleDiscordMessagingAction(
});
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean"
? params.includeArchived
: undefined;
typeof params.includeArchived === "boolean" ? params.includeArchived : undefined;
const before = readStringParam(params, "before");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
@@ -387,14 +377,8 @@ export async function handleDiscordMessagingAction(
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const channelIdList = [
...(channelIds ?? []),
...(channelId ? [channelId] : []),
];
const authorIdList = [
...(authorIds ?? []),
...(authorId ? [authorId] : []),
];
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
const results = await searchMessagesDiscord({
guildId,
content,

View File

@@ -1,10 +1,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
banMemberDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
} from "../../discord/send.js";
import { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord } from "../../discord/send.js";
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
export async function handleDiscordModerationAction(
@@ -24,8 +20,7 @@ export async function handleDiscordModerationAction(
required: true,
});
const durationMinutes =
typeof params.durationMinutes === "number" &&
Number.isFinite(params.durationMinutes)
typeof params.durationMinutes === "number" && Number.isFinite(params.durationMinutes)
? params.durationMinutes
: undefined;
const until = readStringParam(params, "until");
@@ -65,8 +60,7 @@ export async function handleDiscordModerationAction(
});
const reason = readStringParam(params, "reason");
const deleteMessageDays =
typeof params.deleteMessageDays === "number" &&
Number.isFinite(params.deleteMessageDays)
typeof params.deleteMessageDays === "number" && Number.isFinite(params.deleteMessageDays)
? params.deleteMessageDays
: undefined;
await banMemberDiscord({

View File

@@ -42,8 +42,7 @@ vi.mock("../../discord/send.js", () => ({
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) =>
fetchChannelPermissionsDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
@@ -51,17 +50,14 @@ vi.mock("../../discord/send.js", () => ({
pinMessageDiscord: (...args: unknown[]) => pinMessageDiscord(...args),
reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
readMessagesDiscord: (...args: unknown[]) => readMessagesDiscord(...args),
removeChannelPermissionDiscord: (...args: unknown[]) =>
removeChannelPermissionDiscord(...args),
removeOwnReactionsDiscord: (...args: unknown[]) =>
removeOwnReactionsDiscord(...args),
removeChannelPermissionDiscord: (...args: unknown[]) => removeChannelPermissionDiscord(...args),
removeOwnReactionsDiscord: (...args: unknown[]) => removeOwnReactionsDiscord(...args),
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
searchMessagesDiscord: (...args: unknown[]) => searchMessagesDiscord(...args),
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args),
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
setChannelPermissionDiscord: (...args: unknown[]) =>
setChannelPermissionDiscord(...args),
setChannelPermissionDiscord: (...args: unknown[]) => setChannelPermissionDiscord(...args),
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
}));
@@ -169,11 +165,7 @@ describe("handleDiscordGuildAction - channel management", () => {
it("respects channel gating for channelCreate", async () => {
await expect(
handleDiscordGuildAction(
"channelCreate",
{ guildId: "G1", name: "test" },
channelsDisabled,
),
handleDiscordGuildAction("channelCreate", { guildId: "G1", name: "test" }, channelsDisabled),
).rejects.toThrow(/Discord channel management is disabled/);
});
@@ -239,11 +231,7 @@ describe("handleDiscordGuildAction - channel management", () => {
});
it("deletes a channel", async () => {
await handleDiscordGuildAction(
"channelDelete",
{ channelId: "C1" },
channelsEnabled,
);
await handleDiscordGuildAction("channelDelete", { channelId: "C1" }, channelsEnabled);
expect(deleteChannelDiscord).toHaveBeenCalledWith("C1");
});
@@ -330,11 +318,7 @@ describe("handleDiscordGuildAction - channel management", () => {
});
it("deletes a category", async () => {
await handleDiscordGuildAction(
"categoryDelete",
{ categoryId: "CAT1" },
channelsEnabled,
);
await handleDiscordGuildAction("categoryDelete", { categoryId: "CAT1" }, channelsEnabled);
expect(deleteChannelDiscord).toHaveBeenCalledWith("CAT1");
});

View File

@@ -58,9 +58,7 @@ export function createGatewayTool(opts?: {
const action = readStringParam(params, "action", { required: true });
if (action === "restart") {
if (opts?.config?.commands?.restart !== true) {
throw new Error(
"Gateway restart is disabled. Set commands.restart=true to enable.",
);
throw new Error("Gateway restart is disabled. Set commands.restart=true to enable.");
}
const sessionKey =
typeof params.sessionKey === "string" && params.sessionKey.trim()
@@ -75,9 +73,7 @@ export function createGatewayTool(opts?: {
? params.reason.trim().slice(0, 200)
: undefined;
const note =
typeof params.note === "string" && params.note.trim()
? params.note.trim()
: undefined;
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
const payload: RestartSentinelPayload = {
kind: "restart",
status: "ok",
@@ -114,8 +110,7 @@ export function createGatewayTool(opts?: {
? params.gatewayToken.trim()
: undefined;
const timeoutMs =
typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? Math.max(1, Math.floor(params.timeoutMs))
: undefined;
const gatewayOpts = { gatewayUrl, gatewayToken, timeoutMs };
@@ -135,12 +130,9 @@ export function createGatewayTool(opts?: {
? params.sessionKey.trim()
: opts?.agentSessionKey?.trim() || undefined;
const note =
typeof params.note === "string" && params.note.trim()
? params.note.trim()
: undefined;
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
const restartDelayMs =
typeof params.restartDelayMs === "number" &&
Number.isFinite(params.restartDelayMs)
typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs)
? Math.floor(params.restartDelayMs)
: undefined;
const result = await callGatewayTool("config.apply", gatewayOpts, {
@@ -157,12 +149,9 @@ export function createGatewayTool(opts?: {
? params.sessionKey.trim()
: opts?.agentSessionKey?.trim() || undefined;
const note =
typeof params.note === "string" && params.note.trim()
? params.note.trim()
: undefined;
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
const restartDelayMs =
typeof params.restartDelayMs === "number" &&
Number.isFinite(params.restartDelayMs)
typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs)
? Math.floor(params.restartDelayMs)
: undefined;
const result = await callGatewayTool("update.run", gatewayOpts, {

View File

@@ -1,8 +1,5 @@
import { callGateway } from "../../gateway/call.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../utils/message-channel.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
export const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";

View File

@@ -40,15 +40,11 @@ export function coerceImageAssistantText(params: {
);
}
if (errorMessage) {
throw new Error(
`Image model failed (${params.provider}/${params.model}): ${errorMessage}`,
);
throw new Error(`Image model failed (${params.provider}/${params.model}): ${errorMessage}`);
}
const text = extractAssistantText(params.message);
if (text.trim()) return text.trim();
throw new Error(
`Image model returned no text (${params.provider}/${params.model}).`,
);
throw new Error(`Image model returned no text (${params.provider}/${params.model}).`);
}
export function coerceImageModelConfig(cfg?: ClawdbotConfig): ImageModelConfig {
@@ -56,10 +52,8 @@ export function coerceImageModelConfig(cfg?: ClawdbotConfig): ImageModelConfig {
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
const primary =
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
const fallbacks =
typeof imageModel === "object" ? (imageModel?.fallbacks ?? []) : [];
const primary = typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
const fallbacks = typeof imageModel === "object" ? (imageModel?.fallbacks ?? []) : [];
return {
...(primary?.trim() ? { primary: primary.trim() } : {}),
...(fallbacks.length > 0 ? { fallbacks } : {}),
@@ -70,9 +64,7 @@ export function resolveProviderVisionModelFromConfig(params: {
cfg?: ClawdbotConfig;
provider: string;
}): string | null {
const providerCfg = params.cfg?.models?.providers?.[
params.provider
] as unknown as
const providerCfg = params.cfg?.models?.providers?.[params.provider] as unknown as
| { models?: Array<{ id?: string; input?: string[] }> }
| undefined;
const models = providerCfg?.models ?? [];
@@ -87,9 +79,7 @@ export function resolveProviderVisionModelFromConfig(params: {
: null;
const picked =
preferMinimaxVl ??
models.find(
(m) => Boolean((m?.id ?? "").trim()) && m.input?.includes("image"),
);
models.find((m) => Boolean((m?.id ?? "").trim()) && m.input?.includes("image"));
const id = (picked?.id ?? "").trim();
return id ? `${params.provider}/${id}` : null;
}

View File

@@ -5,11 +5,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import {
__testing,
createImageTool,
resolveImageModelConfigForTool,
} from "./image-tool.js";
import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js";
async function writeAuthProfiles(agentDir: string, profiles: unknown) {
await fs.mkdir(agentDir, { recursive: true });
@@ -41,9 +37,7 @@ describe("image tool implicit imageModel config", () => {
});
it("stays disabled without auth when no pairing is possible", async () => {
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-image-"),
);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-"));
const cfg: ClawdbotConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.2" } } },
};
@@ -52,9 +46,7 @@ describe("image tool implicit imageModel config", () => {
});
it("pairs minimax primary with MiniMax-VL-01 (and fallbacks) when auth exists", async () => {
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-image-"),
);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-"));
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
vi.stubEnv("OPENAI_API_KEY", "openai-test");
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
@@ -69,9 +61,7 @@ describe("image tool implicit imageModel config", () => {
});
it("pairs a custom provider when it declares an image-capable model", async () => {
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-image-"),
);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-"));
await writeAuthProfiles(agentDir, {
version: 1,
profiles: {
@@ -98,9 +88,7 @@ describe("image tool implicit imageModel config", () => {
});
it("prefers explicit agents.defaults.imageModel", async () => {
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-image-"),
);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-"));
const cfg: ClawdbotConfig = {
agents: {
defaults: {
@@ -115,9 +103,7 @@ describe("image tool implicit imageModel config", () => {
});
it("sandboxes image paths like the read tool", async () => {
const stateDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-image-sandbox-"),
);
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-sandbox-"));
const agentDir = path.join(stateDir, "agent");
const sandboxRoot = path.join(stateDir, "sandbox");
await fs.mkdir(agentDir, { recursive: true });
@@ -132,19 +118,17 @@ describe("image tool implicit imageModel config", () => {
expect(tool).not.toBeNull();
if (!tool) throw new Error("expected image tool");
await expect(
tool.execute("t1", { image: "https://example.com/a.png" }),
).rejects.toThrow(/Sandboxed image tool does not allow remote URLs/i);
await expect(tool.execute("t1", { image: "https://example.com/a.png" })).rejects.toThrow(
/Sandboxed image tool does not allow remote URLs/i,
);
await expect(
tool.execute("t2", { image: "../escape.png" }),
).rejects.toThrow(/escapes sandbox root/i);
await expect(tool.execute("t2", { image: "../escape.png" })).rejects.toThrow(
/escapes sandbox root/i,
);
});
it("rewrites inbound absolute paths into sandbox media/inbound", async () => {
const stateDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-image-sandbox-"),
);
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-sandbox-"));
const agentDir = path.join(stateDir, "agent");
const sandboxRoot = path.join(stateDir, "sandbox");
await fs.mkdir(agentDir, { recursive: true });
@@ -190,9 +174,7 @@ describe("image tool implicit imageModel config", () => {
});
expect(fetch).toHaveBeenCalledTimes(1);
expect((res.details as { rewrittenFrom?: string }).rewrittenFrom).toContain(
"photo.png",
);
expect((res.details as { rewrittenFrom?: string }).rewrittenFrom).toContain("photo.png");
});
});
@@ -207,9 +189,9 @@ describe("image tool data URL support", () => {
});
it("rejects non-image data URLs", () => {
expect(() =>
__testing.decodeDataUrl("data:text/plain;base64,SGVsbG8="),
).toThrow(/Unsupported data URL type/i);
expect(() => __testing.decodeDataUrl("data:text/plain;base64,SGVsbG8=")).toThrow(
/Unsupported data URL type/i,
);
});
});
@@ -245,9 +227,7 @@ describe("image tool MiniMax VLM routing", () => {
// @ts-expect-error partial global
global.fetch = fetch;
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-minimax-vlm-"),
);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-minimax-vlm-"));
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
const cfg: ClawdbotConfig = {
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },
@@ -265,9 +245,9 @@ describe("image tool MiniMax VLM routing", () => {
const [url, init] = fetch.mock.calls[0];
expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm");
expect(init?.method).toBe("POST");
expect(
String((init?.headers as Record<string, string>)?.Authorization),
).toBe("Bearer minimax-test");
expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe(
"Bearer minimax-test",
);
expect(String(init?.body)).toContain('"prompt":"Describe the image."');
expect(String(init?.body)).toContain('"image_url":"data:image/png;base64,');
@@ -289,9 +269,7 @@ describe("image tool MiniMax VLM routing", () => {
// @ts-expect-error partial global
global.fetch = fetch;
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-minimax-vlm-"),
);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-minimax-vlm-"));
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
const cfg: ClawdbotConfig = {
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },

View File

@@ -8,19 +8,13 @@ import {
complete,
type Model,
} from "@mariozechner/pi-ai";
import {
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveUserPath } from "../../utils.js";
import { loadWebMedia } from "../../web/media.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "../auth-profiles.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { minimaxUnderstandImage } from "../minimax-vlm.js";
import { getApiKeyForModel, resolveEnvApiKey } from "../model-auth.js";
@@ -48,24 +42,15 @@ function resolveDefaultModelRef(cfg?: ClawdbotConfig): {
provider: string;
model: string;
} {
const modelConfig = cfg?.agents?.defaults?.model as
| { primary?: string }
| string
| undefined;
const raw =
typeof modelConfig === "string"
? modelConfig.trim()
: modelConfig?.primary?.trim();
const modelConfig = cfg?.agents?.defaults?.model as { primary?: string } | string | undefined;
const raw = typeof modelConfig === "string" ? modelConfig.trim() : modelConfig?.primary?.trim();
const parsed =
parseModelRef(raw ?? "", DEFAULT_PROVIDER) ??
({ provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL } as const);
return { provider: parsed.provider, model: parsed.model };
}
function hasAuthForProvider(params: {
provider: string;
agentDir: string;
}): boolean {
function hasAuthForProvider(params: { provider: string; agentDir: string }): boolean {
if (resolveEnvApiKey(params.provider)?.apiKey) return true;
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
@@ -156,33 +141,18 @@ export function resolveImageModelConfigForTool(params: {
return null;
}
function pickMaxBytes(
cfg?: ClawdbotConfig,
maxBytesMb?: number,
): number | undefined {
if (
typeof maxBytesMb === "number" &&
Number.isFinite(maxBytesMb) &&
maxBytesMb > 0
) {
function pickMaxBytes(cfg?: ClawdbotConfig, maxBytesMb?: number): number | undefined {
if (typeof maxBytesMb === "number" && Number.isFinite(maxBytesMb) && maxBytesMb > 0) {
return Math.floor(maxBytesMb * 1024 * 1024);
}
const configured = cfg?.agents?.defaults?.mediaMaxMb;
if (
typeof configured === "number" &&
Number.isFinite(configured) &&
configured > 0
) {
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * 1024 * 1024);
}
return undefined;
}
function buildImageContext(
prompt: string,
base64: string,
mimeType: string,
): Context {
function buildImageContext(prompt: string, base64: string, mimeType: string): Context {
return {
messages: [
{
@@ -201,8 +171,7 @@ async function resolveSandboxedImagePath(params: {
sandboxRoot: string;
imagePath: string;
}): Promise<{ resolved: string; rewrittenFrom?: string }> {
const normalize = (p: string) =>
p.startsWith("file://") ? p.slice("file://".length) : p;
const normalize = (p: string) => (p.startsWith("file://") ? p.slice("file://".length) : p);
const filePath = normalize(params.imagePath);
try {
const out = await assertSandboxPath({
@@ -269,9 +238,7 @@ async function runImagePrompt(params: {
throw new Error(`Unknown model: ${provider}/${modelId}`);
}
if (!model.input?.includes("image")) {
throw new Error(
`Model does not support images: ${provider}/${modelId}`,
);
throw new Error(`Model does not support images: ${provider}/${modelId}`);
}
const apiKeyInfo = await getApiKeyForModel({
model,
@@ -291,11 +258,7 @@ async function runImagePrompt(params: {
return { text, provider: model.provider, model: model.id };
}
const context = buildImageContext(
params.prompt,
params.base64,
params.mimeType,
);
const context = buildImageContext(params.prompt, params.base64, params.mimeType);
const message = (await complete(model, context, {
apiKey: apiKeyInfo.apiKey,
maxTokens: 512,
@@ -351,12 +314,8 @@ export function createImageTool(options?: {
maxBytesMb: Type.Optional(Type.Number()),
}),
execute: async (_toolCallId, args) => {
const record =
args && typeof args === "object"
? (args as Record<string, unknown>)
: {};
const imageRawInput =
typeof record.image === "string" ? record.image.trim() : "";
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
const imageRawInput = typeof record.image === "string" ? record.image.trim() : "";
const imageRaw = imageRawInput.startsWith("@")
? imageRawInput.slice(1).trim()
: imageRawInput;
@@ -372,13 +331,7 @@ export function createImageTool(options?: {
const isFileUrl = /^file:/i.test(imageRaw);
const isHttpUrl = /^https?:\/\//i.test(imageRaw);
const isDataUrl = /^data:/i.test(imageRaw);
if (
hasScheme &&
!looksLikeWindowsDrivePath &&
!isFileUrl &&
!isHttpUrl &&
!isDataUrl
) {
if (hasScheme && !looksLikeWindowsDrivePath && !isFileUrl && !isHttpUrl && !isDataUrl) {
return {
content: [
{
@@ -397,11 +350,8 @@ export function createImageTool(options?: {
? record.prompt.trim()
: DEFAULT_PROMPT;
const modelOverride =
typeof record.model === "string" && record.model.trim()
? record.model.trim()
: undefined;
const maxBytesMb =
typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
typeof record.model === "string" && record.model.trim() ? record.model.trim() : undefined;
const maxBytesMb = typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
const maxBytes = pickMaxBytes(options?.config, maxBytesMb);
const sandboxRoot = options?.sandboxRoot?.trim();
@@ -415,19 +365,18 @@ export function createImageTool(options?: {
if (imageRaw.startsWith("~")) return resolveUserPath(imageRaw);
return imageRaw;
})();
const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } =
isDataUrl
? { resolved: "" }
: sandboxRoot
? await resolveSandboxedImagePath({
sandboxRoot,
imagePath: resolvedImage,
})
: {
resolved: resolvedImage.startsWith("file://")
? resolvedImage.slice("file://".length)
: resolvedImage,
};
const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl
? { resolved: "" }
: sandboxRoot
? await resolveSandboxedImagePath({
sandboxRoot,
imagePath: resolvedImage,
})
: {
resolved: resolvedImage.startsWith("file://")
? resolvedImage.slice("file://".length)
: resolvedImage,
};
const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved;
const media = isDataUrl

View File

@@ -9,10 +9,7 @@ import {
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
} from "../../gateway/protocol/client-info.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { stringEnum } from "../schema/typebox.js";
@@ -132,10 +129,9 @@ type MessageToolOptions = {
function buildMessageToolSchema(cfg: ClawdbotConfig) {
const actions = listChannelMessageActions(cfg);
const includeButtons = supportsChannelMessageButtons(cfg);
return buildMessageToolSchemaFromActions(
actions.length > 0 ? actions : ["send"],
{ includeButtons },
);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeButtons,
});
}
function resolveAgentAccountId(value?: string): string | undefined {
@@ -146,9 +142,7 @@ function resolveAgentAccountId(value?: string): string | undefined {
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
const schema = options?.config
? buildMessageToolSchema(options.config)
: MessageToolSchema;
const schema = options?.config ? buildMessageToolSchema(options.config) : MessageToolSchema;
return {
label: "Message",

View File

@@ -99,26 +99,19 @@ export function createNodesTool(): AnyAgentTool {
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
switch (action) {
case "status":
return jsonResult(
await callGatewayTool("node.list", gatewayOpts, {}),
);
return jsonResult(await callGatewayTool("node.list", gatewayOpts, {}));
case "describe": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
return jsonResult(
await callGatewayTool("node.describe", gatewayOpts, { nodeId }),
);
return jsonResult(await callGatewayTool("node.describe", gatewayOpts, { nodeId }));
}
case "pending":
return jsonResult(
await callGatewayTool("node.pair.list", gatewayOpts, {}),
);
return jsonResult(await callGatewayTool("node.pair.list", gatewayOpts, {}));
case "approve": {
const requestId = readStringParam(params, "requestId", {
required: true,
@@ -153,16 +146,9 @@ export function createNodesTool(): AnyAgentTool {
params: {
title: title.trim() || undefined,
body: body.trim() || undefined,
sound:
typeof params.sound === "string" ? params.sound : undefined,
priority:
typeof params.priority === "string"
? params.priority
: undefined,
delivery:
typeof params.delivery === "string"
? params.delivery
: undefined,
sound: typeof params.sound === "string" ? params.sound : undefined,
priority: typeof params.priority === "string" ? params.priority : undefined,
delivery: typeof params.delivery === "string" ? params.delivery : undefined,
},
idempotencyKey: crypto.randomUUID(),
});
@@ -172,9 +158,7 @@ export function createNodesTool(): AnyAgentTool {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facingRaw =
typeof params.facing === "string"
? params.facing.toLowerCase()
: "both";
typeof params.facing === "string" ? params.facing.toLowerCase() : "both";
const facings: CameraFacing[] =
facingRaw === "both"
? ["front", "back"]
@@ -184,18 +168,15 @@ export function createNodesTool(): AnyAgentTool {
throw new Error("invalid facing (front|back|both)");
})();
const maxWidth =
typeof params.maxWidth === "number" &&
Number.isFinite(params.maxWidth)
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth)
? params.maxWidth
: undefined;
const quality =
typeof params.quality === "number" &&
Number.isFinite(params.quality)
typeof params.quality === "number" && Number.isFinite(params.quality)
? params.quality
: undefined;
const delayMs =
typeof params.delayMs === "number" &&
Number.isFinite(params.delayMs)
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? params.delayMs
: undefined;
const deviceId =
@@ -227,13 +208,10 @@ export function createNodesTool(): AnyAgentTool {
normalizedFormat !== "jpeg" &&
normalizedFormat !== "png"
) {
throw new Error(
`unsupported camera.snap format: ${payload.format}`,
);
throw new Error(`unsupported camera.snap format: ${payload.format}`);
}
const isJpeg =
normalizedFormat === "jpg" || normalizedFormat === "jpeg";
const isJpeg = normalizedFormat === "jpg" || normalizedFormat === "jpeg";
const filePath = cameraTempPath({
kind: "snap",
facing,
@@ -245,8 +223,7 @@ export function createNodesTool(): AnyAgentTool {
type: "image",
data: payload.base64,
mimeType:
imageMimeFromFormat(payload.format) ??
(isJpeg ? "image/jpeg" : "image/png"),
imageMimeFromFormat(payload.format) ?? (isJpeg ? "image/jpeg" : "image/png"),
});
details.push({
facing,
@@ -269,32 +246,24 @@ export function createNodesTool(): AnyAgentTool {
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload =
raw && typeof raw.payload === "object" && raw.payload !== null
? raw.payload
: {};
raw && typeof raw.payload === "object" && raw.payload !== null ? raw.payload : {};
return jsonResult(payload);
}
case "camera_clip": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facing =
typeof params.facing === "string"
? params.facing.toLowerCase()
: "front";
const facing = typeof params.facing === "string" ? params.facing.toLowerCase() : "front";
if (facing !== "front" && facing !== "back") {
throw new Error("invalid facing (front|back)");
}
const durationMs =
typeof params.durationMs === "number" &&
Number.isFinite(params.durationMs)
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 3000;
const includeAudio =
typeof params.includeAudio === "boolean"
? params.includeAudio
: true;
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
@@ -332,25 +301,19 @@ export function createNodesTool(): AnyAgentTool {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const durationMs =
typeof params.durationMs === "number" &&
Number.isFinite(params.durationMs)
typeof params.durationMs === "number" && Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 10_000;
const fps =
typeof params.fps === "number" && Number.isFinite(params.fps)
? params.fps
: 10;
typeof params.fps === "number" && Number.isFinite(params.fps) ? params.fps : 10;
const screenIndex =
typeof params.screenIndex === "number" &&
Number.isFinite(params.screenIndex)
typeof params.screenIndex === "number" && Number.isFinite(params.screenIndex)
? params.screenIndex
: 0;
const includeAudio =
typeof params.includeAudio === "boolean"
? params.includeAudio
: true;
typeof params.includeAudio === "boolean" ? params.includeAudio : true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "screen.record",
@@ -368,10 +331,7 @@ export function createNodesTool(): AnyAgentTool {
typeof params.outPath === "string" && params.outPath.trim()
? params.outPath.trim()
: screenRecordTempPath({ ext: payload.format || "mp4" });
const written = await writeScreenRecordToFile(
filePath,
payload.base64,
);
const written = await writeScreenRecordToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${written.path}` }],
details: {
@@ -387,8 +347,7 @@ export function createNodesTool(): AnyAgentTool {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const maxAgeMs =
typeof params.maxAgeMs === "number" &&
Number.isFinite(params.maxAgeMs)
typeof params.maxAgeMs === "number" && Number.isFinite(params.maxAgeMs)
? params.maxAgeMs
: undefined;
const desiredAccuracy =
@@ -419,23 +378,17 @@ export function createNodesTool(): AnyAgentTool {
const nodeId = await resolveNodeId(gatewayOpts, node);
const commandRaw = params.command;
if (!commandRaw) {
throw new Error(
"command required (argv array, e.g. ['echo', 'Hello'])",
);
throw new Error("command required (argv array, e.g. ['echo', 'Hello'])");
}
if (!Array.isArray(commandRaw)) {
throw new Error(
"command must be an array of strings (argv), e.g. ['echo', 'Hello']",
);
throw new Error("command must be an array of strings (argv), e.g. ['echo', 'Hello']");
}
const command = commandRaw.map((c) => String(c));
if (command.length === 0) {
throw new Error("command must not be empty");
}
const cwd =
typeof params.cwd === "string" && params.cwd.trim()
? params.cwd.trim()
: undefined;
typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : undefined;
const env = parseEnvPairs(params.env);
const commandTimeoutMs = parseTimeoutMs(params.commandTimeoutMs);
const invokeTimeoutMs = parseTimeoutMs(params.invokeTimeoutMs);

View File

@@ -43,21 +43,13 @@ type PairingList = {
};
function parseNodeList(value: unknown): NodeListNode[] {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : [];
}
function parsePairingList(value: unknown): PairingList {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
const pending = Array.isArray(obj.pending)
? (obj.pending as PendingRequest[])
: [];
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
const pending = Array.isArray(obj.pending) ? (obj.pending as PendingRequest[]) : [];
const paired = Array.isArray(obj.paired) ? (obj.paired as PairedNode[]) : [];
return { pending, paired };
}

View File

@@ -6,10 +6,7 @@ import {
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import {
buildAllowedModelSet,
@@ -20,10 +17,7 @@ import {
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
import {
getFollowupQueueDepth,
resolveQueueSettings,
} from "../../auto-reply/reply/queue.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
import { buildStatusMessage } from "../../auto-reply/status.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
@@ -45,10 +39,7 @@ import {
} from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "./sessions-helpers.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
const SessionStatusToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
@@ -179,10 +170,8 @@ async function resolveModelOverride(params: {
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const currentProvider =
params.sessionEntry?.providerOverride?.trim() || configDefault.provider;
const currentModel =
params.sessionEntry?.modelOverride?.trim() || configDefault.model;
const currentProvider = params.sessionEntry?.providerOverride?.trim() || configDefault.provider;
const currentModel = params.sessionEntry?.modelOverride?.trim() || configDefault.model;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
@@ -209,8 +198,7 @@ async function resolveModelOverride(params: {
throw new Error(`Model "${key}" is not allowed.`);
}
const isDefault =
resolved.ref.provider === configDefault.provider &&
resolved.ref.model === configDefault.model;
resolved.ref.provider === configDefault.provider && resolved.ref.model === configDefault.model;
return {
kind: "set",
provider: resolved.ref.provider,
@@ -234,15 +222,12 @@ export function createSessionStatusTool(opts?: {
const cfg = opts?.config ?? loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const requestedKeyRaw =
readStringParam(params, "sessionKey") ?? opts?.agentSessionKey;
const requestedKeyRaw = readStringParam(params, "sessionKey") ?? opts?.agentSessionKey;
if (!requestedKeyRaw?.trim()) {
throw new Error("sessionKey required");
}
const agentId = resolveAgentIdFromSessionKey(
opts?.agentSessionKey ?? requestedKeyRaw,
);
const agentId = resolveAgentIdFromSessionKey(opts?.agentSessionKey ?? requestedKeyRaw);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
@@ -289,8 +274,7 @@ export function createSessionStatusTool(opts?: {
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const providerForCard =
resolved.entry.providerOverride?.trim() || configured.provider;
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
const usageProvider = resolveUsageProviderId(providerForCard);
let usageLine: string | undefined;
if (usageProvider) {
@@ -316,22 +300,18 @@ export function createSessionStatusTool(opts?: {
resolved.key.includes(":group:") ||
resolved.key.includes(":channel:");
const groupActivation = isGroup
? (normalizeGroupActivation(resolved.entry.groupActivation) ??
"mention")
? (normalizeGroupActivation(resolved.entry.groupActivation) ?? "mention")
: undefined;
const queueSettings = resolveQueueSettings({
cfg,
channel:
resolved.entry.channel ?? resolved.entry.lastChannel ?? "unknown",
channel: resolved.entry.channel ?? resolved.entry.lastChannel ?? "unknown",
sessionEntry: resolved.entry,
});
const queueKey = resolved.key ?? resolved.entry.sessionId;
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
const queueOverrides = Boolean(
resolved.entry.queueDebounceMs ??
resolved.entry.queueCap ??
resolved.entry.queueDrop,
resolved.entry.queueDebounceMs ?? resolved.entry.queueCap ?? resolved.entry.queueDrop,
);
const statusText = buildStatusMessage({

View File

@@ -43,9 +43,7 @@ describe("resolveAnnounceTarget", () => {
accountId: "work",
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const first = callGatewayMock.mock.calls[0]?.[0] as
| { method?: string }
| undefined;
const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined;
expect(first).toBeDefined();
expect(first?.method).toBe("sessions.list");
});

View File

@@ -1,7 +1,4 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { callGateway } from "../../gateway/call.js";
import type { AnnounceTarget } from "./sessions-send-helpers.js";
import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js";
@@ -35,13 +32,9 @@ export async function resolveAnnounceTarget(params: {
const match =
sessions.find((entry) => entry?.key === params.sessionKey) ??
sessions.find((entry) => entry?.key === params.displayKey);
const channel =
typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
const channel = typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
const to = typeof match?.lastTo === "string" ? match.lastTo : undefined;
const accountId =
typeof match?.lastAccountId === "string"
? match.lastAccountId
: undefined;
const accountId = typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined;
if (channel && to) return { channel, to, accountId };
} catch {
// ignore

View File

@@ -15,21 +15,13 @@ export function resolveMainSessionAlias(cfg: ClawdbotConfig) {
return { mainKey, alias, scope };
}
export function resolveDisplaySessionKey(params: {
key: string;
alias: string;
mainKey: string;
}) {
export function resolveDisplaySessionKey(params: { key: string; alias: string; mainKey: string }) {
if (params.key === params.alias) return "main";
if (params.key === params.mainKey) return "main";
return params.key;
}
export function resolveInternalSessionKey(params: {
key: string;
alias: string;
mainKey: string;
}) {
export function resolveInternalSessionKey(params: { key: string; alias: string; mainKey: string }) {
if (params.key === "main") return params.alias;
return params.key;
}
@@ -46,11 +38,7 @@ export function classifySessionKind(params: {
if (key.startsWith("hook:")) return "hook";
if (key.startsWith("node-") || key.startsWith("node:")) return "node";
if (params.gatewayKind === "group") return "group";
if (
key.startsWith("group:") ||
key.includes(":group:") ||
key.includes(":channel:")
) {
if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) {
return "group";
}
return "other";
@@ -62,12 +50,7 @@ export function deriveChannel(params: {
channel?: string | null;
lastChannel?: string | null;
}): string {
if (
params.kind === "cron" ||
params.kind === "hook" ||
params.kind === "node"
)
return "internal";
if (params.kind === "cron" || params.kind === "hook" || params.kind === "node") return "internal";
const channel = normalizeKey(params.channel ?? undefined);
if (channel) return channel;
const lastChannel = normalizeKey(params.lastChannel ?? undefined);

View File

@@ -22,9 +22,7 @@ const SessionsHistoryToolSchema = Type.Object({
includeTools: Type.Optional(Type.Boolean()),
});
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
function resolveSandboxSessionToolsVisibility(cfg: ReturnType<typeof loadConfig>) {
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
@@ -99,9 +97,7 @@ export function createSessionsHistoryTool(opts?: {
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
@@ -117,9 +113,7 @@ export function createSessionsHistoryTool(opts?: {
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = normalizeAgentId(
parseAgentSessionKey(resolvedKey)?.agentId,
);
const targetAgentId = normalizeAgentId(parseAgentSessionKey(resolvedKey)?.agentId);
const isCrossAgent = requesterAgentId !== targetAgentId;
if (isCrossAgent) {
if (!a2aEnabled) {
@@ -146,12 +140,8 @@ export function createSessionsHistoryTool(opts?: {
method: "chat.history",
params: { sessionKey: resolvedKey, limit },
})) as { messages?: unknown[] };
const rawMessages = Array.isArray(result?.messages)
? result.messages
: [];
const messages = includeTools
? rawMessages
: stripToolMessages(rawMessages);
const rawMessages = Array.isArray(result?.messages) ? result.messages : [];
const messages = includeTools ? rawMessages : stripToolMessages(rawMessages);
return jsonResult({
sessionKey: resolveDisplaySessionKey({
key: sessionKey,

View File

@@ -6,8 +6,7 @@ vi.mock("../../gateway/call.js", () => ({
}));
vi.mock("../../config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../config/config.js")>();
const actual = await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: () =>

View File

@@ -51,9 +51,7 @@ const SessionsListToolSchema = Type.Object({
messageLimit: Type.Optional(Type.Number({ minimum: 0 })),
});
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
function resolveSandboxSessionToolsVisibility(cfg: ReturnType<typeof loadConfig>) {
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
@@ -91,22 +89,18 @@ export function createSessionsListTool(opts?: {
const allowedKindsList = (kindsRaw ?? []).filter((value) =>
["main", "group", "cron", "hook", "node", "other"].includes(value),
);
const allowedKinds = allowedKindsList.length
? new Set(allowedKindsList)
: undefined;
const allowedKinds = allowedKindsList.length ? new Set(allowedKindsList) : undefined;
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: undefined;
const activeMinutes =
typeof params.activeMinutes === "number" &&
Number.isFinite(params.activeMinutes)
typeof params.activeMinutes === "number" && Number.isFinite(params.activeMinutes)
? Math.max(1, Math.floor(params.activeMinutes))
: undefined;
const messageLimitRaw =
typeof params.messageLimit === "number" &&
Number.isFinite(params.messageLimit)
typeof params.messageLimit === "number" && Number.isFinite(params.messageLimit)
? Math.max(0, Math.floor(params.messageLimit))
: 0;
const messageLimit = Math.min(messageLimitRaw, 20);
@@ -129,9 +123,7 @@ export function createSessionsListTool(opts?: {
const storePath = typeof list?.path === "string" ? list.path : undefined;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
@@ -154,21 +146,17 @@ export function createSessionsListTool(opts?: {
const key = typeof entry.key === "string" ? entry.key : "";
if (!key) continue;
const entryAgentId = normalizeAgentId(
parseAgentSessionKey(key)?.agentId,
);
const entryAgentId = normalizeAgentId(parseAgentSessionKey(key)?.agentId);
const crossAgent = entryAgentId !== requesterAgentId;
if (crossAgent) {
if (!a2aEnabled) continue;
if (!matchesAllow(requesterAgentId) || !matchesAllow(entryAgentId))
continue;
if (!matchesAllow(requesterAgentId) || !matchesAllow(entryAgentId)) continue;
}
if (key === "unknown") continue;
if (key === "global" && alias !== "global") continue;
const gatewayKind =
typeof entry.kind === "string" ? entry.kind : undefined;
const gatewayKind = typeof entry.kind === "string" ? entry.kind : undefined;
const kind = classifySessionKind({ key, gatewayKind, alias, mainKey });
if (allowedKinds && !allowedKinds.has(kind)) continue;
@@ -178,14 +166,10 @@ export function createSessionsListTool(opts?: {
mainKey,
});
const entryChannel =
typeof entry.channel === "string" ? entry.channel : undefined;
const lastChannel =
typeof entry.lastChannel === "string" ? entry.lastChannel : undefined;
const entryChannel = typeof entry.channel === "string" ? entry.channel : undefined;
const lastChannel = typeof entry.lastChannel === "string" ? entry.lastChannel : undefined;
const lastAccountId =
typeof entry.lastAccountId === "string"
? entry.lastAccountId
: undefined;
typeof entry.lastAccountId === "string" ? entry.lastAccountId : undefined;
const derivedChannel = deriveChannel({
key,
kind,
@@ -193,8 +177,7 @@ export function createSessionsListTool(opts?: {
lastChannel,
});
const sessionId =
typeof entry.sessionId === "string" ? entry.sessionId : undefined;
const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined;
const transcriptPath =
sessionId && storePath
? path.join(path.dirname(storePath), `${sessionId}.jsonl`)
@@ -205,40 +188,18 @@ export function createSessionsListTool(opts?: {
kind,
channel: derivedChannel,
label: typeof entry.label === "string" ? entry.label : undefined,
displayName:
typeof entry.displayName === "string"
? entry.displayName
: undefined,
updatedAt:
typeof entry.updatedAt === "number" ? entry.updatedAt : undefined,
displayName: typeof entry.displayName === "string" ? entry.displayName : undefined,
updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : undefined,
sessionId,
model: typeof entry.model === "string" ? entry.model : undefined,
contextTokens:
typeof entry.contextTokens === "number"
? entry.contextTokens
: undefined,
totalTokens:
typeof entry.totalTokens === "number"
? entry.totalTokens
: undefined,
thinkingLevel:
typeof entry.thinkingLevel === "string"
? entry.thinkingLevel
: undefined,
verboseLevel:
typeof entry.verboseLevel === "string"
? entry.verboseLevel
: undefined,
systemSent:
typeof entry.systemSent === "boolean"
? entry.systemSent
: undefined,
contextTokens: typeof entry.contextTokens === "number" ? entry.contextTokens : undefined,
totalTokens: typeof entry.totalTokens === "number" ? entry.totalTokens : undefined,
thinkingLevel: typeof entry.thinkingLevel === "string" ? entry.thinkingLevel : undefined,
verboseLevel: typeof entry.verboseLevel === "string" ? entry.verboseLevel : undefined,
systemSent: typeof entry.systemSent === "boolean" ? entry.systemSent : undefined,
abortedLastRun:
typeof entry.abortedLastRun === "boolean"
? entry.abortedLastRun
: undefined,
sendPolicy:
typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
typeof entry.abortedLastRun === "boolean" ? entry.abortedLastRun : undefined,
sendPolicy: typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
lastChannel,
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
lastAccountId,
@@ -255,14 +216,9 @@ export function createSessionsListTool(opts?: {
method: "chat.history",
params: { sessionKey: resolvedKey, limit: messageLimit },
})) as { messages?: unknown[] };
const rawMessages = Array.isArray(history?.messages)
? history.messages
: [];
const rawMessages = Array.isArray(history?.messages) ? history.messages : [];
const filtered = stripToolMessages(rawMessages);
row.messages =
filtered.length > messageLimit
? filtered.slice(-messageLimit)
: filtered;
row.messages = filtered.length > messageLimit ? filtered.slice(-messageLimit) : filtered;
}
rows.push(row);

View File

@@ -1,7 +1,4 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ClawdbotConfig } from "../../config/config.js";
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
@@ -15,14 +12,9 @@ export type AnnounceTarget = {
accountId?: string;
};
export function resolveAnnounceTargetFromKey(
sessionKey: string,
): AnnounceTarget | null {
export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null {
const rawParts = sessionKey.split(":").filter(Boolean);
const parts =
rawParts.length >= 3 && rawParts[0] === "agent"
? rawParts.slice(2)
: rawParts;
const parts = rawParts.length >= 3 && rawParts[0] === "agent" ? rawParts.slice(2) : rawParts;
if (parts.length < 3) return null;
const [channelRaw, kind, ...rest] = parts;
if (kind !== "group" && kind !== "channel") return null;
@@ -37,9 +29,7 @@ export function resolveAnnounceTargetFromKey(
: `group:${id}`
: id;
const normalized = normalizedChannel
? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(
kindTarget,
)
? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget)
: undefined;
return { channel, to: normalized ?? kindTarget };
}
@@ -72,9 +62,7 @@ export function buildAgentToAgentReplyContext(params: {
maxTurns: number;
}) {
const currentLabel =
params.currentRole === "requester"
? "Agent 1 (requester)"
: "Agent 2 (target)";
params.currentRole === "requester" ? "Agent 1 (requester)" : "Agent 2 (target)";
const lines = [
"Agent-to-agent reply step:",
`Current agent: ${currentLabel}.`,
@@ -86,9 +74,7 @@ export function buildAgentToAgentReplyContext(params: {
? `Agent 1 (requester) channel: ${params.requesterChannel}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel
? `Agent 2 (target) channel: ${params.targetChannel}.`
: undefined,
params.targetChannel ? `Agent 2 (target) channel: ${params.targetChannel}.` : undefined,
`If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`,
].filter(Boolean);
return lines.join("\n");
@@ -112,16 +98,12 @@ export function buildAgentToAgentAnnounceContext(params: {
? `Agent 1 (requester) channel: ${params.requesterChannel}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel
? `Agent 2 (target) channel: ${params.targetChannel}.`
: undefined,
params.targetChannel ? `Agent 2 (target) channel: ${params.targetChannel}.` : undefined,
`Original request: ${params.originalMessage}`,
params.roundOneReply
? `Round 1 reply: ${params.roundOneReply}`
: "Round 1 reply: (not available).",
params.latestReply
? `Latest reply: ${params.latestReply}`
: "Latest reply: (not available).",
params.latestReply ? `Latest reply: ${params.latestReply}` : "Latest reply: (not available).",
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
"Any other reply will be posted to the target channel.",
"After this reply, the agent-to-agent conversation is over.",

View File

@@ -66,9 +66,7 @@ export async function runSessionsSendA2AFlow(params: {
let incomingMessage = latestReply;
for (let turn = 1; turn <= params.maxPingPongTurns; turn += 1) {
const currentRole =
currentSessionKey === params.requesterSessionKey
? "requester"
: "target";
currentSessionKey === params.requesterSessionKey ? "requester" : "target";
const replyPrompt = buildAgentToAgentReplyContext({
requesterSessionKey: params.requesterSessionKey,
requesterChannel: params.requesterChannel,
@@ -112,12 +110,7 @@ export async function runSessionsSendA2AFlow(params: {
timeoutMs: params.announceTimeoutMs,
lane: AGENT_LANE_NESTED,
});
if (
announceTarget &&
announceReply &&
announceReply.trim() &&
!isAnnounceSkip(announceReply)
) {
if (announceTarget && announceReply && announceReply.trim() && !isAnnounceSkip(announceReply)) {
try {
await callGateway({
method: "send",

View File

@@ -6,8 +6,7 @@ vi.mock("../../gateway/call.js", () => ({
}));
vi.mock("../../config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../config/config.js")>();
const actual = await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: () =>

View File

@@ -24,17 +24,12 @@ import {
resolveMainSessionAlias,
stripToolMessages,
} from "./sessions-helpers.js";
import {
buildAgentToAgentMessageContext,
resolvePingPongTurns,
} from "./sessions-send-helpers.js";
import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./sessions-send-helpers.js";
import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js";
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
label: Type.Optional(
Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH }),
),
label: Type.Optional(Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH })),
agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
@@ -56,8 +51,7 @@ export function createSessionsSendTool(opts?: {
const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility =
cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({
@@ -74,9 +68,7 @@ export function createSessionsSendTool(opts?: {
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
@@ -92,8 +84,7 @@ export function createSessionsSendTool(opts?: {
const sessionKeyParam = readStringParam(params, "sessionKey");
const labelParam = readStringParam(params, "label")?.trim() || undefined;
const labelAgentIdParam =
readStringParam(params, "agentId")?.trim() || undefined;
const labelAgentIdParam = readStringParam(params, "agentId")?.trim() || undefined;
if (sessionKeyParam && labelParam) {
return jsonResult({
runId: crypto.randomUUID(),
@@ -114,9 +105,7 @@ export function createSessionsSendTool(opts?: {
let sessionKey = sessionKeyParam;
if (!sessionKey && labelParam) {
const requesterAgentId = requesterInternalKey
? normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
)
? normalizeAgentId(parseAgentSessionKey(requesterInternalKey)?.agentId)
: undefined;
const requestedAgentId = labelAgentIdParam
? normalizeAgentId(labelAgentIdParam)
@@ -131,16 +120,11 @@ export function createSessionsSendTool(opts?: {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Sandboxed sessions_send label lookup is limited to this agent",
error: "Sandboxed sessions_send label lookup is limited to this agent",
});
}
if (
requesterAgentId &&
requestedAgentId &&
requestedAgentId !== requesterAgentId
) {
if (requesterAgentId && requestedAgentId && requestedAgentId !== requesterAgentId) {
if (!a2aEnabled) {
return jsonResult({
runId: crypto.randomUUID(),
@@ -149,15 +133,11 @@ export function createSessionsSendTool(opts?: {
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
});
}
if (
!matchesAllow(requesterAgentId) ||
!matchesAllow(requestedAgentId)
) {
if (!matchesAllow(requesterAgentId) || !matchesAllow(requestedAgentId)) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
error: "Agent-to-agent messaging denied by tools.agentToAgent.allow.",
});
}
}
@@ -174,8 +154,7 @@ export function createSessionsSendTool(opts?: {
params: resolveParams,
timeoutMs: 10_000,
})) as { key?: unknown };
resolvedKey =
typeof resolved?.key === "string" ? resolved.key.trim() : "";
resolvedKey = typeof resolved?.key === "string" ? resolved.key.trim() : "";
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (restrictToSpawned) {
@@ -245,8 +224,7 @@ export function createSessionsSendTool(opts?: {
}
}
const timeoutSeconds =
typeof params.timeoutSeconds === "number" &&
Number.isFinite(params.timeoutSeconds)
typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds)
? Math.max(0, Math.floor(params.timeoutSeconds))
: 30;
const timeoutMs = timeoutSeconds * 1000;
@@ -261,9 +239,7 @@ export function createSessionsSendTool(opts?: {
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = normalizeAgentId(
parseAgentSessionKey(resolvedKey)?.agentId,
);
const targetAgentId = normalizeAgentId(parseAgentSessionKey(resolvedKey)?.agentId);
const isCrossAgent = requesterAgentId !== targetAgentId;
if (isCrossAgent) {
if (!a2aEnabled) {
@@ -279,8 +255,7 @@ export function createSessionsSendTool(opts?: {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
error: "Agent-to-agent messaging denied by tools.agentToAgent.allow.",
sessionKey: displayKey,
});
}
@@ -337,11 +312,7 @@ export function createSessionsSendTool(opts?: {
});
} catch (err) {
const messageText =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return jsonResult({
runId,
status: "error",
@@ -362,11 +333,7 @@ export function createSessionsSendTool(opts?: {
}
} catch (err) {
const messageText =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return jsonResult({
runId,
status: "error",
@@ -390,11 +357,7 @@ export function createSessionsSendTool(opts?: {
waitError = typeof wait?.error === "string" ? wait.error : undefined;
} catch (err) {
const messageText =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return jsonResult({
runId,
status: messageText.includes("gateway timeout") ? "timeout" : "error",
@@ -424,11 +387,8 @@ export function createSessionsSendTool(opts?: {
method: "chat.history",
params: { sessionKey: resolvedKey, limit: 50 },
})) as { messages?: unknown[] };
const filtered = stripToolMessages(
Array.isArray(history?.messages) ? history.messages : [],
);
const last =
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const reply = last ? extractAssistantText(last) : undefined;
startA2AFlow(reply ?? undefined);

View File

@@ -68,14 +68,12 @@ export function createSessionsSpawnTool(opts?: {
: "keep";
const runTimeoutSeconds = (() => {
const explicit =
typeof params.runTimeoutSeconds === "number" &&
Number.isFinite(params.runTimeoutSeconds)
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
? Math.max(0, Math.floor(params.runTimeoutSeconds))
: undefined;
if (explicit !== undefined) return explicit;
const legacy =
typeof params.timeoutSeconds === "number" &&
Number.isFinite(params.timeoutSeconds)
typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds)
? Math.max(0, Math.floor(params.timeoutSeconds))
: undefined;
return legacy ?? 0;
@@ -86,10 +84,7 @@ export function createSessionsSpawnTool(opts?: {
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const requesterSessionKey = opts?.agentSessionKey;
if (
typeof requesterSessionKey === "string" &&
isSubagentSessionKey(requesterSessionKey)
) {
if (typeof requesterSessionKey === "string" && isSubagentSessionKey(requesterSessionKey)) {
return jsonResult({
status: "forbidden",
error: "sessions_spawn is not allowed from sub-agent sessions",
@@ -115,9 +110,7 @@ export function createSessionsSpawnTool(opts?: {
? normalizeAgentId(requestedAgentId)
: requesterAgentId;
if (targetAgentId !== requesterAgentId) {
const allowAgents =
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
[];
const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? [];
const allowAny = allowAgents.some((value) => value.trim() === "*");
const normalizedTargetId = targetAgentId.toLowerCase();
const allowSet = new Set(
@@ -154,14 +147,9 @@ export function createSessionsSpawnTool(opts?: {
modelApplied = true;
} catch (err) {
const messageText =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
const recoverable =
messageText.includes("invalid model") ||
messageText.includes("model not allowed");
messageText.includes("invalid model") || messageText.includes("model not allowed");
if (!recoverable) {
return jsonResult({
status: "error",
@@ -204,11 +192,7 @@ export function createSessionsSpawnTool(opts?: {
}
} catch (err) {
const messageText =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return jsonResult({
status: "error",
error: messageText,

View File

@@ -27,8 +27,7 @@ vi.mock("../../slack/actions.js", () => ({
pinSlackMessage: (...args: unknown[]) => pinSlackMessage(...args),
reactSlackMessage: (...args: unknown[]) => reactSlackMessage(...args),
readSlackMessages: (...args: unknown[]) => readSlackMessages(...args),
removeOwnSlackReactions: (...args: unknown[]) =>
removeOwnSlackReactions(...args),
removeOwnSlackReactions: (...args: unknown[]) => removeOwnSlackReactions(...args),
removeSlackReaction: (...args: unknown[]) => removeSlackReaction(...args),
sendSlackMessage: (...args: unknown[]) => sendSlackMessage(...args),
unpinSlackMessage: (...args: unknown[]) => unpinSlackMessage(...args),
@@ -122,14 +121,10 @@ describe("handleSlackAction", () => {
},
cfg,
);
expect(sendSlackMessage).toHaveBeenCalledWith(
"channel:C123",
"Hello thread",
{
mediaUrl: undefined,
threadTs: "1234567890.123456",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", {
mediaUrl: undefined,
threadTs: "1234567890.123456",
});
});
it("auto-injects threadTs from context when replyToMode=all", async () => {
@@ -148,14 +143,10 @@ describe("handleSlackAction", () => {
replyToMode: "all",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith(
"channel:C123",
"Auto-threaded",
{
mediaUrl: undefined,
threadTs: "1111111111.111111",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Auto-threaded", {
mediaUrl: undefined,
threadTs: "1111111111.111111",
});
});
it("replyToMode=first threads first message then stops", async () => {
@@ -187,14 +178,10 @@ describe("handleSlackAction", () => {
cfg,
context,
);
expect(sendSlackMessage).toHaveBeenLastCalledWith(
"channel:C123",
"Second",
{
mediaUrl: undefined,
threadTs: undefined,
},
);
expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "Second", {
mediaUrl: undefined,
threadTs: undefined,
});
});
it("replyToMode=first marks hasRepliedRef even when threadTs is explicit", async () => {
@@ -218,14 +205,10 @@ describe("handleSlackAction", () => {
cfg,
context,
);
expect(sendSlackMessage).toHaveBeenLastCalledWith(
"channel:C123",
"Explicit",
{
mediaUrl: undefined,
threadTs: "2222222222.222222",
},
);
expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "Explicit", {
mediaUrl: undefined,
threadTs: "2222222222.222222",
});
expect(hasRepliedRef.value).toBe(true);
await handleSlackAction(
@@ -233,29 +216,21 @@ describe("handleSlackAction", () => {
cfg,
context,
);
expect(sendSlackMessage).toHaveBeenLastCalledWith(
"channel:C123",
"Second",
{
mediaUrl: undefined,
threadTs: undefined,
},
);
expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "Second", {
mediaUrl: undefined,
threadTs: undefined,
});
});
it("replyToMode=first without hasRepliedRef does not thread", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{ action: "sendMessage", to: "channel:C123", content: "No ref" },
cfg,
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "first",
// no hasRepliedRef
},
);
await handleSlackAction({ action: "sendMessage", to: "channel:C123", content: "No ref" }, cfg, {
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "first",
// no hasRepliedRef
});
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "No ref", {
mediaUrl: undefined,
threadTs: undefined,
@@ -300,14 +275,10 @@ describe("handleSlackAction", () => {
replyToMode: "all",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith(
"channel:C999",
"Different channel",
{
mediaUrl: undefined,
threadTs: undefined,
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Different channel", {
mediaUrl: undefined,
threadTs: undefined,
});
});
it("explicit threadTs overrides context threadTs", async () => {
@@ -327,14 +298,10 @@ describe("handleSlackAction", () => {
replyToMode: "all",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith(
"channel:C123",
"Explicit thread",
{
mediaUrl: undefined,
threadTs: "2222222222.222222",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Explicit thread", {
mediaUrl: undefined,
threadTs: "2222222222.222222",
});
});
it("handles channel target without prefix when replyToMode=all", async () => {

View File

@@ -17,19 +17,9 @@ import {
sendSlackMessage,
unpinSlackMessage,
} from "../../slack/actions.js";
import {
createActionGate,
jsonResult,
readReactionParams,
readStringParam,
} from "./common.js";
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
const messagingActions = new Set([
"sendMessage",
"editMessage",
"deleteMessage",
"readMessages",
]);
const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
const reactionsActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
@@ -73,11 +63,7 @@ function resolveThreadTsFromContext(
if (context.replyToMode === "all") {
return context.currentThreadTs;
}
if (
context.replyToMode === "first" &&
context.hasRepliedRef &&
!context.hasRepliedRef.value
) {
if (context.replyToMode === "first" && context.hasRepliedRef && !context.hasRepliedRef.value) {
context.hasRepliedRef.value = true;
return context.currentThreadTs;
}
@@ -157,9 +143,7 @@ export async function handleSlackAction(
// threadTs: once we send a message to the current channel, consider the
// first reply "used" so later tool calls don't auto-thread again.
if (context?.hasRepliedRef && context.currentChannelId) {
const normalizedTarget = to.startsWith("channel:")
? to.slice("channel:".length)
: to;
const normalizedTarget = to.startsWith("channel:") ? to.slice("channel:".length) : to;
if (normalizedTarget === context.currentChannelId) {
context.hasRepliedRef.value = true;
}
@@ -204,9 +188,7 @@ export async function handleSlackAction(
});
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw)
? limitRaw
: undefined;
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const result = await readSlackMessages(channelId, {
@@ -270,9 +252,7 @@ export async function handleSlackAction(
if (!isActionEnabled("emojiList")) {
throw new Error("Slack emoji list is disabled.");
}
const emojis = accountOpts
? await listSlackEmojis(accountOpts)
: await listSlackEmojis();
const emojis = accountOpts ? await listSlackEmojis(accountOpts) : await listSlackEmojis();
return jsonResult({ ok: true, emojis });
}

View File

@@ -1,10 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import {
handleTelegramAction,
readTelegramButtons,
} from "./telegram-actions.js";
import { handleTelegramAction, readTelegramButtons } from "./telegram-actions.js";
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
const sendMessageTelegram = vi.fn(async () => ({

View File

@@ -1,10 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
reactMessageTelegram,
sendMessageTelegram,
} from "../../telegram/send.js";
import { reactMessageTelegram, sendMessageTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import {
createActionGate,
@@ -47,23 +44,18 @@ export function readTelegramButtons(
}
return row.map((button, buttonIndex) => {
if (!button || typeof button !== "object") {
throw new Error(
`buttons[${rowIndex}][${buttonIndex}] must be an object`,
);
throw new Error(`buttons[${rowIndex}][${buttonIndex}] must be an object`);
}
const text =
typeof (button as { text?: unknown }).text === "string"
? (button as { text: string }).text.trim()
: "";
const callbackData =
typeof (button as { callback_data?: unknown }).callback_data ===
"string"
typeof (button as { callback_data?: unknown }).callback_data === "string"
? (button as { callback_data: string }).callback_data.trim()
: "";
if (!text || !callbackData) {
throw new Error(
`buttons[${rowIndex}][${buttonIndex}] requires text and callback_data`,
);
throw new Error(`buttons[${rowIndex}][${buttonIndex}] requires text and callback_data`);
}
if (callbackData.length > 64) {
throw new Error(
@@ -124,10 +116,7 @@ export async function handleTelegramAction(
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
const buttons = readTelegramButtons(params);
if (
buttons &&
!hasInlineButtonsCapability({ cfg, accountId: accountId ?? undefined })
) {
if (buttons && !hasInlineButtonsCapability({ cfg, accountId: accountId ?? undefined })) {
throw new Error(
'Telegram inline buttons requested but not enabled. Add "inlineButtons" to channels.telegram.capabilities (or channels.telegram.accounts.<id>.capabilities).',
);

View File

@@ -24,17 +24,12 @@ describe("handleWhatsAppAction", () => {
},
enabledConfig,
);
expect(sendReactionWhatsApp).toHaveBeenCalledWith(
"123@s.whatsapp.net",
"msg1",
"✅",
{
verbose: false,
fromMe: undefined,
participant: undefined,
accountId: undefined,
},
);
expect(sendReactionWhatsApp).toHaveBeenCalledWith("123@s.whatsapp.net", "msg1", "✅", {
verbose: false,
fromMe: undefined,
participant: undefined,
accountId: undefined,
});
});
it("removes reactions on empty emoji", async () => {
@@ -47,17 +42,12 @@ describe("handleWhatsAppAction", () => {
},
enabledConfig,
);
expect(sendReactionWhatsApp).toHaveBeenCalledWith(
"123@s.whatsapp.net",
"msg1",
"",
{
verbose: false,
fromMe: undefined,
participant: undefined,
accountId: undefined,
},
);
expect(sendReactionWhatsApp).toHaveBeenCalledWith("123@s.whatsapp.net", "msg1", "", {
verbose: false,
fromMe: undefined,
participant: undefined,
accountId: undefined,
});
});
it("removes reactions when remove flag set", async () => {
@@ -71,17 +61,12 @@ describe("handleWhatsAppAction", () => {
},
enabledConfig,
);
expect(sendReactionWhatsApp).toHaveBeenCalledWith(
"123@s.whatsapp.net",
"msg1",
"",
{
verbose: false,
fromMe: undefined,
participant: undefined,
accountId: undefined,
},
);
expect(sendReactionWhatsApp).toHaveBeenCalledWith("123@s.whatsapp.net", "msg1", "", {
verbose: false,
fromMe: undefined,
participant: undefined,
accountId: undefined,
});
});
it("passes account scope and sender flags", async () => {
@@ -97,17 +82,12 @@ describe("handleWhatsAppAction", () => {
},
enabledConfig,
);
expect(sendReactionWhatsApp).toHaveBeenCalledWith(
"123@s.whatsapp.net",
"msg1",
"🎉",
{
verbose: false,
fromMe: true,
participant: "999@s.whatsapp.net",
accountId: "work",
},
);
expect(sendReactionWhatsApp).toHaveBeenCalledWith("123@s.whatsapp.net", "msg1", "🎉", {
verbose: false,
fromMe: true,
participant: "999@s.whatsapp.net",
accountId: "work",
});
});
it("respects reaction gating", async () => {

View File

@@ -2,12 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { sendReactionWhatsApp } from "../../web/outbound.js";
import {
createActionGate,
jsonResult,
readReactionParams,
readStringParam,
} from "./common.js";
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
export async function handleWhatsAppAction(
params: Record<string, unknown>,