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

@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
chunkMarkdownText,
chunkText,
resolveTextChunkLimit,
} from "./chunk.js";
import { chunkMarkdownText, chunkText, resolveTextChunkLimit } from "./chunk.js";
function expectFencesBalanced(chunks: string[]) {
for (const chunk of chunks) {
@@ -32,10 +28,7 @@ type ChunkCase = {
expected: string[];
};
function runChunkCases(
chunker: (text: string, limit: number) => string[],
cases: ChunkCase[],
) {
function runChunkCases(chunker: (text: string, limit: number) => string[], cases: ChunkCase[]) {
for (const { name, text, limit, expected } of cases) {
it(name, () => {
expect(chunker(text, limit)).toEqual(expected);
@@ -84,21 +77,15 @@ describe("chunkText", () => {
it("prefers breaking at a newline before the limit", () => {
const text = `paragraph one line\n\nparagraph two starts here and continues`;
const chunks = chunkText(text, 40);
expect(chunks).toEqual([
"paragraph one line",
"paragraph two starts here and continues",
]);
expect(chunks).toEqual(["paragraph one line", "paragraph two starts here and continues"]);
});
it("otherwise breaks at the last whitespace under the limit", () => {
const text =
"This is a message that should break nicely near a word boundary.";
const text = "This is a message that should break nicely near a word boundary.";
const chunks = chunkText(text, 30);
expect(chunks[0].length).toBeLessThanOrEqual(30);
expect(chunks[1].length).toBeLessThanOrEqual(30);
expect(chunks.join(" ").replace(/\s+/g, " ").trim()).toBe(
text.replace(/\s+/g, " ").trim(),
);
expect(chunks.join(" ").replace(/\s+/g, " ").trim()).toBe(text.replace(/\s+/g, " ").trim());
});
it("falls back to a hard break when no whitespace is present", () => {

View File

@@ -4,11 +4,7 @@
import type { ChannelId } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
findFenceSpanAt,
isSafeFenceBreak,
parseFenceSpans,
} from "../markdown/fences.js";
import { findFenceSpanAt, isSafeFenceBreak, parseFenceSpans } from "../markdown/fences.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
@@ -58,9 +54,7 @@ export function resolveTextChunkLimit(
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return undefined;
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
const providerConfig = (channelsConfig?.[provider] ??
(cfg as Record<string, unknown> | undefined)?.[provider]) as
| ProviderChunkConfig
| undefined;
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
return resolveChunkLimitForProvider(providerConfig, accountId);
})();
if (typeof providerOverride === "number" && providerOverride > 0) {
@@ -96,12 +90,8 @@ export function chunkText(text: string, limit: number): string[] {
}
// If we broke on whitespace/newline, skip that separator; for hard breaks keep it.
const brokeOnSeparator =
breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(
remaining.length,
breakIdx + (brokeOnSeparator ? 1 : 0),
);
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
remaining = remaining.slice(nextStart).trimStart();
}
@@ -145,10 +135,7 @@ export function chunkMarkdownText(text: string, limit: number): string[] {
const maxIdxIfAlreadyNewline = limit - closeLine.length;
let pickedNewline = false;
let lastNewline = remaining.lastIndexOf(
"\n",
Math.max(0, maxIdxIfAlreadyNewline - 1),
);
let lastNewline = remaining.lastIndexOf("\n", Math.max(0, maxIdxIfAlreadyNewline - 1));
while (lastNewline !== -1) {
const candidateBreak = lastNewline + 1;
if (candidateBreak < minProgressIdx) break;
@@ -173,27 +160,19 @@ export function chunkMarkdownText(text: string, limit: number): string[] {
const fenceAtBreak = findFenceSpanAt(spans, breakIdx);
fenceToSplit =
fenceAtBreak && fenceAtBreak.start === initialFence.start
? fenceAtBreak
: undefined;
fenceAtBreak && fenceAtBreak.start === initialFence.start ? fenceAtBreak : undefined;
}
let rawChunk = remaining.slice(0, breakIdx);
if (!rawChunk) break;
const brokeOnSeparator =
breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(
remaining.length,
breakIdx + (brokeOnSeparator ? 1 : 0),
);
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
let next = remaining.slice(nextStart);
if (fenceToSplit) {
const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`;
rawChunk = rawChunk.endsWith("\n")
? `${rawChunk}${closeLine}`
: `${rawChunk}\n${closeLine}`;
rawChunk = rawChunk.endsWith("\n") ? `${rawChunk}${closeLine}` : `${rawChunk}\n${closeLine}`;
next = `${fenceToSplit.openLine}\n${next}`;
} else {
next = stripLeadingNewlines(next);
@@ -213,13 +192,9 @@ function stripLeadingNewlines(value: string): string {
return i > 0 ? value.slice(i) : value;
}
function pickSafeBreakIndex(
window: string,
spans: ReturnType<typeof parseFenceSpans>,
): number {
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(
window,
(index) => isSafeFenceBreak(spans, index),
function pickSafeBreakIndex(window: string, spans: ReturnType<typeof parseFenceSpans>): number {
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, (index) =>
isSafeFenceBreak(spans, index),
);
if (lastNewline > 0) return lastNewline;

View File

@@ -14,10 +14,7 @@ export type CommandAuthorization = {
to?: string;
};
function resolveProviderFromContext(
ctx: MsgContext,
cfg: ClawdbotConfig,
): ChannelId | undefined {
function resolveProviderFromContext(ctx: MsgContext, cfg: ClawdbotConfig): ChannelId | undefined {
const direct =
normalizeChannelId(ctx.Provider) ??
normalizeChannelId(ctx.Surface) ??
@@ -79,12 +76,9 @@ export function resolveCommandAuthorization(params: {
allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [],
});
const allowAll =
allowFromList.length === 0 ||
allowFromList.some((entry) => entry.trim() === "*");
allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*");
const ownerCandidates = allowAll
? []
: allowFromList.filter((entry) => entry !== "*");
const ownerCandidates = allowAll ? [] : allowFromList.filter((entry) => entry !== "*");
if (!allowAll && ownerCandidates.length === 0 && to) {
const normalizedTo = formatAllowFromList({
dock,

View File

@@ -1,8 +1,5 @@
import { listChannelDocks } from "../channels/dock.js";
import type {
ChatCommandDefinition,
CommandScope,
} from "./commands-registry.types.js";
import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js";
type DefineChatCommandInput = {
key: string;
@@ -14,17 +11,12 @@ type DefineChatCommandInput = {
scope?: CommandScope;
};
function defineChatCommand(
command: DefineChatCommandInput,
): ChatCommandDefinition {
const aliases = (
command.textAliases ?? (command.textAlias ? [command.textAlias] : [])
)
function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition {
const aliases = (command.textAliases ?? (command.textAlias ? [command.textAlias] : []))
.map((alias) => alias.trim())
.filter(Boolean);
const scope =
command.scope ??
(command.nativeName ? (aliases.length ? "both" : "native") : "text");
command.scope ?? (command.nativeName ? (aliases.length ? "both" : "native") : "text");
return {
key: command.key,
nativeName: command.nativeName,
@@ -35,18 +27,12 @@ function defineChatCommand(
};
}
function registerAlias(
commands: ChatCommandDefinition[],
key: string,
...aliases: string[]
): void {
function registerAlias(commands: ChatCommandDefinition[], key: string, ...aliases: string[]): void {
const command = commands.find((entry) => entry.key === key);
if (!command) {
throw new Error(`registerAlias: unknown command key: ${key}`);
}
const existing = new Set(
command.textAliases.map((alias) => alias.trim().toLowerCase()),
);
const existing = new Set(command.textAliases.map((alias) => alias.trim().toLowerCase()));
for (const alias of aliases) {
const trimmed = alias.trim();
if (!trimmed) continue;

View File

@@ -95,9 +95,7 @@ describe("commands registry", () => {
});
it("normalizes telegram-style command mentions for the current bot", () => {
expect(
normalizeCommandBody("/help@clawdbot", { botUsername: "clawdbot" }),
).toBe("/help");
expect(normalizeCommandBody("/help@clawdbot", { botUsername: "clawdbot" })).toBe("/help");
expect(
normalizeCommandBody("/help@clawdbot args", {
botUsername: "clawdbot",
@@ -111,8 +109,8 @@ describe("commands registry", () => {
});
it("keeps telegram-style command mentions for other bots", () => {
expect(
normalizeCommandBody("/help@otherbot", { botUsername: "clawdbot" }),
).toBe("/help@otherbot");
expect(normalizeCommandBody("/help@otherbot", { botUsername: "clawdbot" })).toBe(
"/help@otherbot",
);
});
});

View File

@@ -1,8 +1,5 @@
import type { ClawdbotConfig } from "../config/types.js";
import {
CHAT_COMMANDS,
getNativeCommandSurfaces,
} from "./commands-registry.data.js";
import { CHAT_COMMANDS, getNativeCommandSurfaces } from "./commands-registry.data.js";
import type {
ChatCommandDefinition,
CommandDetection,
@@ -56,35 +53,28 @@ export function listChatCommands(): ChatCommandDefinition[] {
return [...CHAT_COMMANDS];
}
export function isCommandEnabled(
cfg: ClawdbotConfig,
commandKey: string,
): boolean {
export function isCommandEnabled(cfg: ClawdbotConfig, commandKey: string): boolean {
if (commandKey === "config") return cfg.commands?.config === true;
if (commandKey === "debug") return cfg.commands?.debug === true;
if (commandKey === "bash") return cfg.commands?.bash === true;
return true;
}
export function listChatCommandsForConfig(
cfg: ClawdbotConfig,
): ChatCommandDefinition[] {
export function listChatCommandsForConfig(cfg: ClawdbotConfig): ChatCommandDefinition[] {
return CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key));
}
export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.filter(
(command) => command.scope !== "text" && command.nativeName,
).map((command) => ({
name: command.nativeName ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
}));
return CHAT_COMMANDS.filter((command) => command.scope !== "text" && command.nativeName).map(
(command) => ({
name: command.nativeName ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
}),
);
}
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
): NativeCommandSpec[] {
export function listNativeCommandSpecsForConfig(cfg: ClawdbotConfig): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
@@ -94,14 +84,10 @@ export function listNativeCommandSpecsForConfig(
}));
}
export function findCommandByNativeName(
name: string,
): ChatCommandDefinition | undefined {
export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined {
const normalized = name.trim().toLowerCase();
return CHAT_COMMANDS.find(
(command) =>
command.scope !== "text" &&
command.nativeName?.toLowerCase() === normalized,
(command) => command.scope !== "text" && command.nativeName?.toLowerCase() === normalized,
);
}
@@ -110,16 +96,12 @@ export function buildCommandText(commandName: string, args?: string): string {
return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`;
}
export function normalizeCommandBody(
raw: string,
options?: CommandNormalizeOptions,
): string {
export function normalizeCommandBody(raw: string, options?: CommandNormalizeOptions): string {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return trimmed;
const newline = trimmed.indexOf("\n");
const singleLine =
newline === -1 ? trimmed : trimmed.slice(0, newline).trim();
const singleLine = newline === -1 ? trimmed : trimmed.slice(0, newline).trim();
const colonMatch = singleLine.match(/^\/([^\s:]+)\s*:(.*)$/);
const normalized = colonMatch
@@ -151,9 +133,7 @@ export function normalizeCommandBody(
if (!tokenSpec) return commandBody;
if (rest && !tokenSpec.acceptsArgs) return commandBody;
const normalizedRest = rest?.trimStart();
return normalizedRest
? `${tokenSpec.canonical} ${normalizedRest}`
: tokenSpec.canonical;
return normalizedRest ? `${tokenSpec.canonical} ${normalizedRest}` : tokenSpec.canonical;
}
export function isCommandMessage(raw: string): boolean {
@@ -181,9 +161,7 @@ export function getCommandDetection(_cfg?: ClawdbotConfig): CommandDetection {
}
cachedDetection = {
exact,
regex: patterns.length
? new RegExp(`^(?:${patterns.join("|")})$`, "i")
: /$^/,
regex: patterns.length ? new RegExp(`^(?:${patterns.join("|")})$`, "i") : /$^/,
};
return cachedDetection;
}
@@ -225,9 +203,7 @@ export function isNativeCommandSurface(surface?: string): boolean {
return getNativeCommandSurfaces().has(surface.toLowerCase());
}
export function shouldHandleTextCommands(
params: ShouldHandleTextCommandsParams,
): boolean {
export function shouldHandleTextCommands(params: ShouldHandleTextCommandsParams): boolean {
if (params.commandSource === "native") return true;
if (params.cfg.commands?.text !== false) return true;
return !isNativeCommandSurface(params.surface);

View File

@@ -19,9 +19,7 @@ describe("formatAgentEnvelope", () => {
process.env.TZ = originalTz;
expect(body).toBe(
"[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello",
);
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
});
it("formats timestamps in UTC regardless of local timezone", () => {

View File

@@ -2,9 +2,7 @@ import { normalizeCommandBody } from "./commands-registry.js";
export type GroupActivationMode = "mention" | "always";
export function normalizeGroupActivation(
raw?: string | null,
): GroupActivationMode | undefined {
export function normalizeGroupActivation(raw?: string | null): GroupActivationMode | undefined {
const value = raw?.trim().toLowerCase();
if (value === "mention") return "mention";
if (value === "always") return "always";

View File

@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
stripHeartbeatToken,
} from "./heartbeat.js";
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "./heartbeat.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
describe("stripHeartbeatToken", () => {
@@ -18,26 +15,20 @@ describe("stripHeartbeatToken", () => {
text: "",
didStrip: false,
});
expect(stripHeartbeatToken(HEARTBEAT_TOKEN, { mode: "heartbeat" })).toEqual(
{
shouldSkip: true,
text: "",
didStrip: true,
},
);
});
it("drops heartbeats with small junk in heartbeat mode", () => {
expect(
stripHeartbeatToken("HEARTBEAT_OK 🦞", { mode: "heartbeat" }),
).toEqual({
expect(stripHeartbeatToken(HEARTBEAT_TOKEN, { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
});
expect(
stripHeartbeatToken(`🦞 ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
).toEqual({
});
it("drops heartbeats with small junk in heartbeat mode", () => {
expect(stripHeartbeatToken("HEARTBEAT_OK 🦞", { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
});
expect(stripHeartbeatToken(`🦞 ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
@@ -45,9 +36,7 @@ describe("stripHeartbeatToken", () => {
});
it("drops short remainder in heartbeat mode", () => {
expect(
stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
).toEqual({
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
@@ -56,9 +45,7 @@ describe("stripHeartbeatToken", () => {
it("keeps heartbeat replies when remaining content exceeds threshold", () => {
const long = "A".repeat(DEFAULT_HEARTBEAT_ACK_MAX_CHARS + 1);
expect(
stripHeartbeatToken(`${long} ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
).toEqual({
expect(stripHeartbeatToken(`${long} ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" })).toEqual({
shouldSkip: false,
text: long,
didStrip: true,
@@ -66,16 +53,12 @@ describe("stripHeartbeatToken", () => {
});
it("strips token at edges for normal messages", () => {
expect(
stripHeartbeatToken(`${HEARTBEAT_TOKEN} hello`, { mode: "message" }),
).toEqual({
expect(stripHeartbeatToken(`${HEARTBEAT_TOKEN} hello`, { mode: "message" })).toEqual({
shouldSkip: false,
text: "hello",
didStrip: true,
});
expect(
stripHeartbeatToken(`hello ${HEARTBEAT_TOKEN}`, { mode: "message" }),
).toEqual({
expect(stripHeartbeatToken(`hello ${HEARTBEAT_TOKEN}`, { mode: "message" })).toEqual({
shouldSkip: false,
text: "hello",
didStrip: true,
@@ -95,9 +78,7 @@ describe("stripHeartbeatToken", () => {
});
it("strips HTML-wrapped heartbeat tokens", () => {
expect(
stripHeartbeatToken(`<b>${HEARTBEAT_TOKEN}</b>`, { mode: "heartbeat" }),
).toEqual({
expect(stripHeartbeatToken(`<b>${HEARTBEAT_TOKEN}</b>`, { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
@@ -105,9 +86,7 @@ describe("stripHeartbeatToken", () => {
});
it("strips markdown-wrapped heartbeat tokens", () => {
expect(
stripHeartbeatToken(`**${HEARTBEAT_TOKEN}**`, { mode: "heartbeat" }),
).toEqual({
expect(stripHeartbeatToken(`**${HEARTBEAT_TOKEN}**`, { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: true,

View File

@@ -54,9 +54,7 @@ export function stripHeartbeatToken(
const mode: StripHeartbeatMode = opts.mode ?? "message";
const maxAckCharsRaw = opts.maxAckChars;
const parsedAckChars =
typeof maxAckCharsRaw === "string"
? Number(maxAckCharsRaw)
: maxAckCharsRaw;
typeof maxAckCharsRaw === "string" ? Number(maxAckCharsRaw) : maxAckCharsRaw;
const maxAckChars = Math.max(
0,
typeof parsedAckChars === "number" && Number.isFinite(parsedAckChars)
@@ -77,9 +75,7 @@ export function stripHeartbeatToken(
.replace(/[*`~_]+$/, "");
const trimmedNormalized = stripMarkup(trimmed);
const hasToken =
trimmed.includes(HEARTBEAT_TOKEN) ||
trimmedNormalized.includes(HEARTBEAT_TOKEN);
const hasToken = trimmed.includes(HEARTBEAT_TOKEN) || trimmedNormalized.includes(HEARTBEAT_TOKEN);
if (!hasToken) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}
@@ -87,9 +83,7 @@ export function stripHeartbeatToken(
const strippedOriginal = stripTokenAtEdges(trimmed);
const strippedNormalized = stripTokenAtEdges(trimmedNormalized);
const picked =
strippedOriginal.didStrip && strippedOriginal.text
? strippedOriginal
: strippedNormalized;
strippedOriginal.didStrip && strippedOriginal.text ? strippedOriginal : strippedNormalized;
if (!picked.didStrip) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}

View File

@@ -18,9 +18,7 @@ function formatMediaAttachedLine(params: {
}
export function buildInboundMediaNote(ctx: MsgContext): string | undefined {
const pathsFromArray = Array.isArray(ctx.MediaPaths)
? ctx.MediaPaths
: undefined;
const pathsFromArray = Array.isArray(ctx.MediaPaths) ? ctx.MediaPaths : undefined;
const paths =
pathsFromArray && pathsFromArray.length > 0
? pathsFromArray

View File

@@ -17,9 +17,7 @@ export function extractModelDirective(
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
);
const aliases = (options?.aliases ?? [])
.map((alias) => alias.trim())
.filter(Boolean);
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
const aliasMatch =
modelMatch || aliases.length === 0
? null
@@ -41,9 +39,7 @@ export function extractModelDirective(
rawProfile = parts.slice(1).join("@").trim() || undefined;
}
const cleaned = match
? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
: body.trim();
const cleaned = match ? body.replace(match[0], " ").replace(/\s+/g, " ").trim() : body.trim();
return {
cleaned,

View File

@@ -6,19 +6,14 @@ import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.j
import { loadModelCatalog } from "../agents/model-catalog.js";
import { getReplyFromConfig } from "./reply.js";
type RunEmbeddedPiAgent =
typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent;
type RunEmbeddedPiAgent = typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent;
type RunEmbeddedPiAgentParams = Parameters<RunEmbeddedPiAgent>[0];
const piEmbeddedMock = vi.hoisted(() => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn<
ReturnType<RunEmbeddedPiAgent>,
Parameters<RunEmbeddedPiAgent>
>(),
runEmbeddedPiAgent: vi.fn<ReturnType<RunEmbeddedPiAgent>, Parameters<RunEmbeddedPiAgent>>(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -84,9 +83,7 @@ describe("directive behavior", () => {
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
expect(texts).toContain("Thinking level set to xhigh.");
});
});
@@ -113,9 +110,7 @@ describe("directive behavior", () => {
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
expect(texts).toContain("Thinking level set to xhigh.");
});
});
@@ -142,9 +137,7 @@ describe("directive behavior", () => {
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
expect(texts).toContain(
'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.2-codex or openai-codex/gpt-5.1-codex.',
);

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -98,9 +97,7 @@ describe("directive behavior", () => {
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -263,9 +262,7 @@ describe("directive behavior", () => {
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -95,9 +94,7 @@ describe("directive behavior", () => {
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));

View File

@@ -14,8 +14,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -96,9 +95,7 @@ describe("directive behavior", () => {
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "lmstudio",
api: "openai-responses",
models: [
{ id: "kimi-k2-0905-preview", name: "Kimi K2 (Local)" },
],
models: [{ id: "kimi-k2-0905-preview", name: "Kimi K2 (Local)" }],
},
},
},
@@ -188,9 +185,7 @@ describe("directive behavior", () => {
);
const events = drainSystemEvents(MAIN_SESSION_KEY);
expect(events).toContain(
"Model switched to Opus (anthropic/claude-opus-4-5).",
);
expect(events).toContain("Model switched to Opus (anthropic/claude-opus-4-5).");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -95,9 +94,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("Session: agent:main:main");
const optionsLine = text
?.split("\n")
.find((line) => line.trim().startsWith("⚙️"));
const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️"));
expect(optionsLine).toBeTruthy();
expect(optionsLine).not.toContain("elevated");

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -183,9 +182,7 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const optionsLine = text
?.split("\n")
.find((line) => line.trim().startsWith("⚙️"));
const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️"));
expect(optionsLine).toBeTruthy();
expect(optionsLine).toContain("elevated");

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -181,9 +180,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
const optionsLine = text
?.split("\n")
.find((line) => line.trim().startsWith("⚙️"));
const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️"));
expect(optionsLine).toBeTruthy();
expect(optionsLine).not.toContain("elevated");

View File

@@ -12,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -217,9 +216,7 @@ describe("directive behavior", () => {
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "lmstudio",
api: "openai-responses",
models: [
{ id: "minimax-m2.1-gs32", name: "MiniMax M2.1 GS32" },
],
models: [{ id: "minimax-m2.1-gs32", name: "MiniMax M2.1 GS32" }],
},
},
},

View File

@@ -3,11 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import {
loadSessionStore,
resolveSessionKey,
saveSessionStore,
} from "../config/sessions.js";
import { loadSessionStore, resolveSessionKey, saveSessionStore } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
const MAIN_SESSION_KEY = "agent:main:main";
@@ -16,8 +12,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -114,9 +109,7 @@ describe("directive behavior", () => {
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
@@ -189,9 +182,7 @@ describe("directive behavior", () => {
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});

View File

@@ -187,10 +187,7 @@ describe("directive parsing", () => {
});
it("preserves newlines when stripping reply tags", () => {
const res = extractReplyToTag(
"line 1\nline 2 [[reply_to_current]]\n\nline 3",
"msg-2",
);
const res = extractReplyToTag("line 1\nline 2 [[reply_to_current]]\n\nline 3", "msg-2");
expect(res.replyToId).toBe("msg-2");
expect(res.cleaned).toBe("line 1\nline 2\n\nline 3");
});

View File

@@ -26,8 +26,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));

View File

@@ -10,8 +10,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -34,8 +33,7 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
},
{
env: {
CLAWDBOT_BUNDLED_SKILLS_DIR: (home) =>
path.join(home, "bundled-skills"),
CLAWDBOT_BUNDLED_SKILLS_DIR: (home) => path.join(home, "bundled-skills"),
},
prefix: "clawdbot-media-note-",
},

View File

@@ -14,8 +14,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -105,11 +104,7 @@ describe("queue followups", () => {
await Promise.resolve();
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
expect(
prompts.some((p) =>
p.includes("[Queued messages while agent was busy]"),
),
).toBe(true);
expect(prompts.some((p) => p.includes("[Queued messages while agent was busy]"))).toBe(true);
});
});
@@ -132,23 +127,11 @@ describe("queue followups", () => {
drop: "summarize",
});
await getReplyFromConfig(
{ Body: "one", From: "+1002", To: "+2000" },
{},
cfg,
);
await getReplyFromConfig(
{ Body: "two", From: "+1002", To: "+2000" },
{},
cfg,
);
await getReplyFromConfig({ Body: "one", From: "+1002", To: "+2000" }, {}, cfg);
await getReplyFromConfig({ Body: "two", From: "+1002", To: "+2000" }, {}, cfg);
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(false);
await getReplyFromConfig(
{ Body: "three", From: "+1002", To: "+2000" },
{},
cfg,
);
await getReplyFromConfig({ Body: "three", From: "+1002", To: "+2000" }, {}, cfg);
await vi.runAllTimersAsync();
await Promise.resolve();

View File

@@ -9,8 +9,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -228,11 +227,8 @@ describe("RawBody directive parsing", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain(
"[Chat messages since your last reply - for context]",
);
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("[Chat messages since your last reply - for context]");
expect(prompt).toContain("Peter: hello");
expect(prompt).toContain("status please");
expect(prompt).not.toContain("/think:high");

View File

@@ -7,8 +7,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -48,10 +47,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
@@ -126,8 +122,7 @@ describe("group intro prompts", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const extraSystemPrompt =
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
?.extraSystemPrompt ?? "";
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe(
`You are replying inside the Discord group "Release Squad". Group members: Alice, Bob. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
);
@@ -158,8 +153,7 @@ describe("group intro prompts", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const extraSystemPrompt =
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
?.extraSystemPrompt ?? "";
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe(
`You are replying inside the WhatsApp group "Ops". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant). ${groupParticipationNote} Address the specific sender noted in the message context.`,
);
@@ -190,8 +184,7 @@ describe("group intro prompts", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const extraSystemPrompt =
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
?.extraSystemPrompt ?? "";
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe(
`You are replying inside the Telegram group "Dev Chat". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
);

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
@@ -163,9 +159,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const extra =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.extraSystemPrompt ??
"";
const extra = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.extraSystemPrompt ?? "";
expect(extra).toContain("Test Group");
expect(extra).toContain("Activation: always-on");
});
@@ -207,8 +201,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("hello");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("A new session was started via /new or /reset");
});
});

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const MAIN_SESSION_KEY = "agent:main:main";
@@ -135,10 +131,7 @@ describe("trigger handling", () => {
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
});
});
@@ -180,10 +173,7 @@ describe("trigger handling", () => {
expect(text).toContain("tools.elevated.enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined();
});
});

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { loadSessionStore } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
@@ -146,9 +142,7 @@ describe("trigger handling", () => {
expect(text).toContain("Elevated mode disabled.");
const store = loadSessionStore(cfg.session.store);
expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe(
"off",
);
expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("off");
});
});
it("allows elevated directive in groups when mentioned", async () => {
@@ -191,13 +185,8 @@ describe("trigger handling", () => {
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe(
"on",
);
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("on");
});
});
it("allows elevated directive in direct chats without mentions", async () => {
@@ -237,10 +226,7 @@ describe("trigger handling", () => {
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
});
});

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
@@ -199,8 +195,7 @@ describe("trigger handling", () => {
expect(String(blockReplies[0]?.text ?? "")).toContain("Model:");
expect(replies.length).toBe(1);
expect(replies[0]?.text).toBe("agent says hi");
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).not.toContain("/status");
});
});

View File

@@ -7,8 +7,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -48,10 +47,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
@@ -124,8 +120,7 @@ describe("trigger handling", () => {
expect(blockReplies.length).toBe(1);
expect(blockReplies[0]?.text).toContain("Slash commands");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).not.toContain("/commands");
expect(text).toBe("ok");
});
@@ -158,8 +153,7 @@ describe("trigger handling", () => {
expect(blockReplies.length).toBe(1);
expect(blockReplies[0]?.text).toContain("Identity");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).not.toContain("/whoami");
expect(text).toBe("ok");
});

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const MAIN_SESSION_KEY = "agent:main:main";
@@ -173,10 +169,7 @@ describe("trigger handling", () => {
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
});
});
@@ -215,9 +208,7 @@ describe("trigger handling", () => {
});
it("returns a context overflow fallback when the embedded agent throws", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
new Error("Context window exceeded"),
);
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(new Error("Context window exceeded"));
const res = await getReplyFromConfig(
{

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
@@ -101,9 +97,7 @@ afterEach(() => {
describe("trigger handling", () => {
it("includes the error cause when the embedded agent throws", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
new Error("sandbox is not defined"),
);
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(new Error("sandbox is not defined"));
const res = await getReplyFromConfig(
{
@@ -221,12 +215,11 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Group activation set to always");
const store = JSON.parse(
await fs.readFile(cfg.session.store, "utf-8"),
) as Record<string, { groupActivation?: string }>;
expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe(
"always",
);
const store = JSON.parse(await fs.readFile(cfg.session.store, "utf-8")) as Record<
string,
{ groupActivation?: string }
>;
expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe("always");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const MAIN_SESSION_KEY = "agent:main:main";
@@ -135,8 +131,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
// Not allowlisted: inline /status is treated as plain text and is not stripped.
expect(prompt).toContain("/status");
});
@@ -178,8 +173,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("/help");
});
});
@@ -232,10 +226,7 @@ describe("trigger handling", () => {
expect(text).toContain("Send policy set to off");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ sendPolicy?: string }
>;
const store = JSON.parse(storeRaw) as Record<string, { sendPolicy?: string }>;
expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny");
});
});

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { resolveSessionKey } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
@@ -193,8 +189,7 @@ describe("trigger handling", () => {
// stripped from the prompt; the remaining text continues through the agent.
expect(blockReplies.length).toBe(1);
expect(String(blockReplies[0]?.text ?? "").length).toBeGreaterThan(0);
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).not.toContain("/status");
});
});
@@ -225,8 +220,7 @@ describe("trigger handling", () => {
expect(blockReplies.length).toBe(1);
expect(blockReplies[0]?.text).toContain("Help");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).not.toContain("/help");
expect(text).toBe("ok");
});

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -102,10 +101,7 @@ afterEach(() => {
describe("trigger handling", () => {
it("runs /compact as a gated command", async () => {
await withTempHome(async (home) => {
const storePath = join(
tmpdir(),
`clawdbot-session-test-${Date.now()}.json`,
);
const storePath = join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`);
vi.mocked(compactEmbeddedPiSession).mockResolvedValue({
ok: true,
compacted: true,
@@ -182,8 +178,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("Give me the status");
expect(prompt).not.toContain("/thinking high");
expect(prompt).not.toContain("/think high");

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
@@ -135,8 +131,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("hello");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("A new session was started via /new or /reset");
});
});

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
@@ -182,10 +178,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(
text?.startsWith("⚙️ Restarting") ||
text?.startsWith("⚠️ Restart failed"),
).toBe(true);
expect(text?.startsWith("⚙️ Restarting") || text?.startsWith("⚠️ Restart failed")).toBe(true);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -9,8 +9,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -50,10 +49,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { loadSessionStore } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
@@ -119,12 +115,8 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain(
"Pick: /model <#> or /model <provider/model>",
);
expect(normalized).toContain(
"1) claude-opus-4-5 — anthropic, openrouter",
);
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>");
expect(normalized).toContain("1) claude-opus-4-5 — anthropic, openrouter");
expect(normalized).toContain("3) gpt-5.2 — openai, openai-codex");
expect(normalized).toContain("More: /model status");
expect(normalized).not.toContain("reasoning");
@@ -202,9 +194,7 @@ describe("trigger handling", () => {
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBe("openrouter");
expect(store[sessionKey]?.modelOverride).toBe(
"anthropic/claude-opus-4-5",
);
expect(store[sessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5");
});
});
it("selects a model by index via /model <#>", async () => {
@@ -227,9 +217,7 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
"Model set to openai/gpt-5.2",
);
expect(normalizeTestText(text ?? "")).toContain("Model set to openai/gpt-5.2");
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBe("openai");

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -50,15 +49,9 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import {
resolveAgentIdFromSessionKey,
resolveSessionKey,
} from "../config/sessions.js";
import { resolveAgentIdFromSessionKey, resolveSessionKey } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
@@ -104,90 +97,80 @@ afterEach(() => {
});
describe("trigger handling", () => {
it(
"stages inbound media into the sandbox workspace",
{ timeout: 15_000 },
async () => {
await withTempHome(async (home) => {
const inboundDir = join(home, ".clawdbot", "media", "inbound");
await fs.mkdir(inboundDir, { recursive: true });
const mediaPath = join(inboundDir, "photo.jpg");
await fs.writeFile(mediaPath, "test");
it("stages inbound media into the sandbox workspace", { timeout: 15_000 }, async () => {
await withTempHome(async (home) => {
const inboundDir = join(home, ".clawdbot", "media", "inbound");
await fs.mkdir(inboundDir, { recursive: true });
const mediaPath = join(inboundDir, "photo.jpg");
await fs.writeFile(mediaPath, "test");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: {
store: join(home, "sessions.json"),
},
};
const ctx = {
Body: "hi",
From: "group:whatsapp:demo",
To: "+2000",
ChatType: "group" as const,
Provider: "whatsapp" as const,
MediaPath: mediaPath,
MediaType: "image/jpeg",
MediaUrl: mediaPath,
};
const res = await getReplyFromConfig(ctx, {}, cfg);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const stagedPath = `media/inbound/${basename(mediaPath)}`;
expect(prompt).toContain(stagedPath);
expect(prompt).not.toContain(mediaPath);
const sessionKey = resolveSessionKey(
cfg.session?.scope ?? "per-sender",
ctx,
cfg.session?.mainKey,
);
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
});
expect(sandbox).not.toBeNull();
if (!sandbox) {
throw new Error("Expected sandbox to be set");
}
const stagedFullPath = join(
sandbox.workspaceDir,
"media",
"inbound",
basename(mediaPath),
);
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
},
);
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: {
store: join(home, "sessions.json"),
},
};
const ctx = {
Body: "hi",
From: "group:whatsapp:demo",
To: "+2000",
ChatType: "group" as const,
Provider: "whatsapp" as const,
MediaPath: mediaPath,
MediaType: "image/jpeg",
MediaUrl: mediaPath,
};
const res = await getReplyFromConfig(ctx, {}, cfg);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const stagedPath = `media/inbound/${basename(mediaPath)}`;
expect(prompt).toContain(stagedPath);
expect(prompt).not.toContain(mediaPath);
const sessionKey = resolveSessionKey(
cfg.session?.scope ?? "per-sender",
ctx,
cfg.session?.mainKey,
);
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
});
expect(sandbox).not.toBeNull();
if (!sandbox) {
throw new Error("Expected sandbox to be set");
}
const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath));
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
});
});
});

View File

@@ -8,8 +8,7 @@ vi.mock("../agents/pi-embedded.js", () => ({
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -49,10 +48,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import {
abortEmbeddedPiRun,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { loadSessionStore } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
@@ -137,9 +133,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("⚙️ Agent was aborted.");
expect(vi.mocked(abortEmbeddedPiRun)).toHaveBeenCalledWith(
targetSessionId,
);
expect(vi.mocked(abortEmbeddedPiRun)).toHaveBeenCalledWith(targetSessionId);
const store = loadSessionStore(cfg.session.store);
expect(store[targetSessionKey]?.abortedLastRun).toBe(true);
});

View File

@@ -9,10 +9,7 @@ import {
} from "../../config/sessions.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import {
normalizeCommandBody,
shouldHandleTextCommands,
} from "../commands-registry.js";
import { normalizeCommandBody, shouldHandleTextCommands } from "../commands-registry.js";
import type { MsgContext } from "../templating.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
@@ -82,9 +79,7 @@ export async function tryFastAbortFromMessage(params: {
config: cfg,
});
// Use RawBody/CommandBody for abort detection (clean message without structural context).
const raw = stripStructuralPrefixes(
ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "",
);
const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "");
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
const normalized = normalizeCommandBody(stripped);

View File

@@ -17,27 +17,18 @@ import {
saveSessionStore,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import {
emitAgentEvent,
registerAgentRunContext,
} from "../../infra/agent-events.js";
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import type { TemplateContext } from "../templating.js";
import type { VerboseLevel } from "../thinking.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import {
buildThreadingToolContext,
resolveEnforceFinalTag,
} from "./agent-runner-utils.js";
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
import type { FollowupRun } from "./queue.js";
import { parseReplyDirectives } from "./reply-directives.js";
import {
applyReplyTagsToPayload,
isRenderablePayload,
} from "./reply-payloads.js";
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
import type { TypingSignaler } from "./typing-mode.js";
export type AgentRunLoopResult =
@@ -94,12 +85,9 @@ export async function runAgentTurnWithFallback(params: {
while (true) {
try {
const allowPartialStream = !(
params.followupRun.run.reasoningLevel === "stream" &&
params.opts?.onReasoningStream
params.followupRun.run.reasoningLevel === "stream" && params.opts?.onReasoningStream
);
const normalizeStreamingText = (
payload: ReplyPayload,
): { text?: string; skip: boolean } => {
const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
if (!allowPartialStream) return { skip: true };
let text = payload.text;
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
@@ -120,9 +108,7 @@ export async function runAgentTurnWithFallback(params: {
}
return { text, skip: false };
};
const handlePartialForTyping = async (
payload: ReplyPayload,
): Promise<string | undefined> => {
const handlePartialForTyping = async (payload: ReplyPayload): Promise<string | undefined> => {
const { text, skip } = normalizeStreamingText(payload);
if (skip || !text) return undefined;
await params.typingSignals.signalTextDelta(text);
@@ -149,10 +135,7 @@ export async function runAgentTurnWithFallback(params: {
startedAt,
},
});
const cliSessionId = getCliSessionId(
params.getActiveSessionEntry(),
provider,
);
const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider);
return runCliAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
@@ -198,8 +181,7 @@ export async function runAgentTurnWithFallback(params: {
return runEmbeddedPiAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
messageProvider:
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: params.sessionCtx.AccountId,
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
@@ -215,10 +197,7 @@ export async function runAgentTurnWithFallback(params: {
prompt: params.commandBody,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
enforceFinalTag: resolveEnforceFinalTag(
params.followupRun.run,
provider,
),
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
provider,
model,
authProfileId: params.followupRun.run.authProfileId,
@@ -233,11 +212,7 @@ export async function runAgentTurnWithFallback(params: {
onPartialReply: allowPartialStream
? async (payload) => {
const textForTyping = await handlePartialForTyping(payload);
if (
!params.opts?.onPartialReply ||
textForTyping === undefined
)
return;
if (!params.opts?.onPartialReply || textForTyping === undefined) return;
await params.opts.onPartialReply({
text: textForTyping,
mediaUrls: payload.mediaUrls,
@@ -248,8 +223,7 @@ export async function runAgentTurnWithFallback(params: {
await params.typingSignals.signalMessageStart();
},
onReasoningStream:
params.typingSignals.shouldStartOnReasoning ||
params.opts?.onReasoningStream
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
? async (payload) => {
await params.typingSignals.signalReasoningDelta();
await params.opts?.onReasoningStream?.({
@@ -261,16 +235,14 @@ export async function runAgentTurnWithFallback(params: {
onAgentEvent: (evt) => {
// Trigger typing when tools start executing
if (evt.stream === "tool") {
const phase =
typeof evt.data.phase === "string" ? evt.data.phase : "";
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "start" || phase === "update") {
void params.typingSignals.signalToolStart();
}
}
// Track auto-compaction completion
if (evt.stream === "compaction") {
const phase =
typeof evt.data.phase === "string" ? evt.data.phase : "";
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
autoCompactionCompleted = true;
@@ -281,8 +253,7 @@ export async function runAgentTurnWithFallback(params: {
params.blockStreamingEnabled && params.opts?.onBlockReply
? async (payload) => {
const { text, skip } = normalizeStreamingText(payload);
const hasPayloadMedia =
(payload.mediaUrls?.length ?? 0) > 0;
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (skip && !hasPayloadMedia) return;
const taggedPayload = applyReplyTagsToPayload(
{
@@ -293,22 +264,14 @@ export async function runAgentTurnWithFallback(params: {
params.sessionCtx.MessageSid,
);
// Let through payloads with audioAsVoice flag even if empty (need to track it)
if (
!isRenderablePayload(taggedPayload) &&
!payload.audioAsVoice
)
return;
const parsed = parseReplyDirectives(
taggedPayload.text ?? "",
{
currentMessageId: params.sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
},
);
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
currentMessageId: params.sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
});
const cleaned = parsed.text || undefined;
const hasRenderableMedia =
Boolean(taggedPayload.mediaUrl) ||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
if (
!cleaned &&
@@ -322,21 +285,16 @@ export async function runAgentTurnWithFallback(params: {
const blockPayload: ReplyPayload = params.applyReplyToMode({
...taggedPayload,
text: cleaned,
audioAsVoice: Boolean(
parsed.audioAsVoice || payload.audioAsVoice,
),
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
replyToCurrent:
taggedPayload.replyToCurrent || parsed.replyToCurrent,
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
});
void params.typingSignals
.signalTextDelta(cleaned ?? taggedPayload.text)
.catch((err) => {
logVerbose(
`block reply typing signal failed: ${String(err)}`,
);
logVerbose(`block reply typing signal failed: ${String(err)}`);
});
params.blockReplyPipeline?.enqueue(blockPayload);
@@ -399,8 +357,7 @@ export async function runAgentTurnWithFallback(params: {
isContextOverflowError(message) ||
/context.*overflow|too large|context window/i.test(message);
const isCompactionFailure = isCompactionFailureError(message);
const isSessionCorruption =
/function call turn comes immediately after/i.test(message);
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
if (
isCompactionFailure &&
@@ -426,8 +383,7 @@ export async function runAgentTurnWithFallback(params: {
try {
// Delete transcript file if it exists
if (corruptedSessionId) {
const transcriptPath =
resolveSessionTranscriptPath(corruptedSessionId);
const transcriptPath = resolveSessionTranscriptPath(corruptedSessionId);
try {
fs.unlinkSync(transcriptPath);
} catch {

View File

@@ -9,9 +9,7 @@ const hasAudioMedia = (urls?: string[]): boolean =>
Boolean(urls?.some((url) => isAudioFileName(url)));
export const isAudioPayload = (payload: ReplyPayload): boolean =>
hasAudioMedia(
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
);
hasAudioMedia(payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined));
export const createShouldEmitToolResult = (params: {
sessionKey?: string;

View File

@@ -3,10 +3,7 @@ import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import {
resolveSandboxConfigForAgent,
resolveSandboxRuntimeStatus,
} from "../../agents/sandbox.js";
import { resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveAgentIdFromSessionKey,
@@ -18,10 +15,7 @@ import { registerAgentRunContext } from "../../infra/agent-events.js";
import type { TemplateContext } from "../templating.js";
import type { VerboseLevel } from "../thinking.js";
import type { GetReplyOptions } from "../types.js";
import {
buildThreadingToolContext,
resolveEnforceFinalTag,
} from "./agent-runner-utils.js";
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
import {
resolveMemoryFlushContextWindowTokens,
resolveMemoryFlushSettings,
@@ -54,10 +48,7 @@ export async function runMemoryFlushIfNeeded(params: {
sessionKey: params.sessionKey,
});
if (!runtime.sandboxed) return true;
const sandboxCfg = resolveSandboxConfigForAgent(
params.cfg,
runtime.agentId,
);
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, runtime.agentId);
return sandboxCfg.workspaceAccess === "rw";
})();
@@ -69,9 +60,7 @@ export async function runMemoryFlushIfNeeded(params: {
shouldRunMemoryFlush({
entry:
params.sessionEntry ??
(params.sessionKey
? params.sessionStore?.[params.sessionKey]
: undefined),
(params.sessionKey ? params.sessionStore?.[params.sessionKey] : undefined),
contextWindowTokens: resolveMemoryFlushContextWindowTokens({
modelId: params.followupRun.run.model ?? params.defaultModel,
agentCfgContextTokens: params.agentCfgContextTokens,
@@ -111,8 +100,7 @@ export async function runMemoryFlushIfNeeded(params: {
runEmbeddedPiAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
messageProvider:
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: params.sessionCtx.AccountId,
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
@@ -128,10 +116,7 @@ export async function runMemoryFlushIfNeeded(params: {
prompt: memoryFlushSettings.prompt,
extraSystemPrompt: flushSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
enforceFinalTag: resolveEnforceFinalTag(
params.followupRun.run,
provider,
),
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
provider,
model,
authProfileId: params.followupRun.run.authProfileId,
@@ -143,8 +128,7 @@ export async function runMemoryFlushIfNeeded(params: {
runId: flushRunId,
onAgentEvent: (evt) => {
if (evt.stream === "compaction") {
const phase =
typeof evt.data.phase === "string" ? evt.data.phase : "";
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
memoryCompactionCompleted = true;
@@ -155,9 +139,7 @@ export async function runMemoryFlushIfNeeded(params: {
});
let memoryFlushCompactionCount =
activeSessionEntry?.compactionCount ??
(params.sessionKey
? activeSessionStore?.[params.sessionKey]?.compactionCount
: 0) ??
(params.sessionKey ? activeSessionStore?.[params.sessionKey]?.compactionCount : 0) ??
0;
if (memoryCompactionCompleted) {
const nextCount = await incrementCompactionCount({

View File

@@ -4,10 +4,7 @@ import { stripHeartbeatToken } from "../heartbeat.js";
import type { OriginatingChannelType } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
import {
formatBunFetchSocketError,
isBunFetchSocketError,
} from "./agent-runner-utils.js";
import { formatBunFetchSocketError, isBunFetchSocketError } from "./agent-runner-utils.js";
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
import { parseReplyDirectives } from "./reply-directives.js";
import {
@@ -52,8 +49,7 @@ export function buildReplyPayloads(params: {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return [];
return [{ ...payload, text: stripped.text }];
});
@@ -105,9 +101,7 @@ export function buildReplyPayloads(params: {
const filteredPayloads = shouldDropFinalPayloads
? []
: params.blockStreamingEnabled
? dedupedPayloads.filter(
(payload) => !params.blockReplyPipeline?.hasSentPayload(payload),
)
? dedupedPayloads.filter((payload) => !params.blockReplyPipeline?.hasSentPayload(payload))
: dedupedPayloads;
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;

View File

@@ -4,11 +4,7 @@ import type { ChannelThreadingToolContext } from "../../channels/plugins/types.j
import { normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import {
estimateUsageCost,
formatTokenCount,
formatUsd,
} from "../../utils/usage-format.js";
import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import type { TemplateContext } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import type { FollowupRun } from "./queue.js";
@@ -73,8 +69,7 @@ export const formatResponseUsageLine = (params: {
const output = usage.output;
if (typeof input !== "number" && typeof output !== "number") return null;
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
const outputLabel =
typeof output === "number" ? formatTokenCount(output) : "?";
const outputLabel = typeof output === "number" ? formatTokenCount(output) : "?";
const cost =
params.showCost && typeof input === "number" && typeof output === "number"
? estimateUsageCost({
@@ -92,10 +87,7 @@ export const formatResponseUsageLine = (params: {
return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`;
};
export const appendUsageLine = (
payloads: ReplyPayload[],
line: string,
): ReplyPayload[] => {
export const appendUsageLine = (payloads: ReplyPayload[], line: string): ReplyPayload[] => {
let index = -1;
for (let i = payloads.length - 1; i >= 0; i -= 1) {
if (payloads[i]?.text) {
@@ -116,7 +108,5 @@ export const appendUsageLine = (
return updated;
};
export const resolveEnforceFinalTag = (
run: FollowupRun["run"],
provider: string,
) => Boolean(run.enforceFinalTag || isReasoningTagProvider(provider));
export const resolveEnforceFinalTag = (run: FollowupRun["run"], provider: string) =>
Boolean(run.enforceFinalTag || isReasoningTagProvider(provider));

View File

@@ -28,8 +28,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -43,9 +42,7 @@ describe("runReplyAgent block streaming", () => {
it("coalesces duplicate text_end block replies", async () => {
const onBlockReply = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(async (params) => {
const block = params.onBlockReply as
| ((payload: { text?: string }) => void)
| undefined;
const block = params.onBlockReply as ((payload: { text?: string }) => void) | undefined;
block?.({ text: "Hello" });
block?.({ text: "Hello" });
return {

View File

@@ -34,8 +34,7 @@ vi.mock("../../agents/cli-runner.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),

View File

@@ -34,8 +34,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -124,9 +123,7 @@ function createMinimalRun(params?: {
describe("runReplyAgent typing (heartbeat)", () => {
it("resets corrupted Gemini sessions and deletes transcripts", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(
path.join(tmpdir(), "clawdbot-session-reset-"),
);
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-reset-"));
process.env.CLAWDBOT_STATE_DIR = stateDir;
try {
const sessionId = "session-corrupt";
@@ -173,9 +170,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
it("keeps sessions intact on other errors", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(
path.join(tmpdir(), "clawdbot-session-noreset-"),
);
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-noreset-"));
process.env.CLAWDBOT_STATE_DIR = stateDir;
try {
const sessionId = "session-ok";

View File

@@ -33,8 +33,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -123,9 +122,7 @@ function createMinimalRun(params?: {
describe("runReplyAgent typing (heartbeat)", () => {
it("retries after compaction failure by resetting the session", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(
path.join(tmpdir(), "clawdbot-session-compaction-reset-"),
);
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-compaction-reset-"));
process.env.CLAWDBOT_STATE_DIR = stateDir;
try {
const sessionId = "session";
@@ -173,9 +170,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
it("retries after context overflow payload by resetting the session", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(
path.join(tmpdir(), "clawdbot-session-overflow-reset-"),
);
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-overflow-reset-"));
process.env.CLAWDBOT_STATE_DIR = stateDir;
try {
const sessionId = "session";
@@ -188,9 +183,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
runEmbeddedPiAgentMock
.mockImplementationOnce(async () => ({
payloads: [
{ text: "Context overflow: prompt too large", isError: true },
],
payloads: [{ text: "Context overflow: prompt too large", isError: true }],
meta: {
durationMs: 1,
error: {

View File

@@ -33,8 +33,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -123,12 +122,10 @@ function createMinimalRun(params?: {
describe("runReplyAgent typing (heartbeat)", () => {
it("signals typing on block replies", async () => {
const onBlockReply = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onBlockReply?.({ text: "chunk", mediaUrls: [] });
return { payloads: [{ text: "final" }], meta: {} };
},
);
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
await params.onBlockReply?.({ text: "chunk", mediaUrls: [] });
return { payloads: [{ text: "final" }], meta: {} };
});
const { run, typing } = createMinimalRun({
typingMode: "message",
@@ -148,12 +145,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
it("signals typing on tool results", async () => {
const onToolResult = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onToolResult?.({ text: "tooling", mediaUrls: [] });
return { payloads: [{ text: "final" }], meta: {} };
},
);
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
await params.onToolResult?.({ text: "tooling", mediaUrls: [] });
return { payloads: [{ text: "final" }], meta: {} };
});
const { run, typing } = createMinimalRun({
typingMode: "message",
@@ -169,12 +164,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
it("skips typing for silent tool results", async () => {
const onToolResult = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] });
return { payloads: [{ text: "final" }], meta: {} };
},
);
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] });
return { payloads: [{ text: "final" }], meta: {} };
});
const { run, typing } = createMinimalRun({
typingMode: "message",
@@ -195,10 +188,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: {
onAgentEvent?: (evt: {
stream: string;
data: Record<string, unknown>;
}) => void;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
}) => {
params.onAgentEvent?.({
stream: "compaction",

View File

@@ -30,8 +30,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -120,12 +119,10 @@ function createMinimalRun(params?: {
describe("runReplyAgent typing (heartbeat)", () => {
it("signals typing for normal runs", async () => {
const onPartialReply = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
},
);
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
});
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: false, onPartialReply },
@@ -137,12 +134,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
expect(typing.startTypingLoop).toHaveBeenCalled();
});
it("signals typing even without consumer partial handler", async () => {
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
},
);
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
});
const { run, typing } = createMinimalRun({
typingMode: "message",
@@ -154,12 +149,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
it("never signals typing for heartbeat runs", async () => {
const onPartialReply = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
},
);
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
});
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: true, onPartialReply },
@@ -172,12 +165,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
it("suppresses partial streaming for NO_REPLY", async () => {
const onPartialReply = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "NO_REPLY" });
return { payloads: [{ text: "NO_REPLY" }], meta: {} };
},
);
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "NO_REPLY" });
return { payloads: [{ text: "NO_REPLY" }], meta: {} };
});
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: false, onPartialReply },
@@ -189,12 +180,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
it("starts typing on assistant message start in message mode", async () => {
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onAssistantMessageStart?.();
return { payloads: [{ text: "final" }], meta: {} };
},
);
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedPiAgentParams) => {
await params.onAssistantMessageStart?.();
return { payloads: [{ text: "final" }], meta: {} };
});
const { run, typing } = createMinimalRun({
typingMode: "message",
@@ -208,9 +197,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: {
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
onReasoningStream?: (payload: {
text?: string;
}) => Promise<void> | void;
onReasoningStream?: (payload: { text?: string }) => Promise<void> | void;
}) => {
await params.onReasoningStream?.({ text: "Reasoning:\n_step_" });
await params.onPartialReply?.({ text: "hi" });
@@ -228,9 +215,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
it("suppresses typing in never mode", async () => {
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: {
onPartialReply?: (payload: { text?: string }) => void;
}) => {
async (params: { onPartialReply?: (payload: { text?: string }) => void }) => {
params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
},

View File

@@ -34,8 +34,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -124,13 +123,9 @@ function createMinimalRun(params?: {
describe("runReplyAgent typing (heartbeat)", () => {
it("still replies even if session reset fails to persist", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(
path.join(tmpdir(), "clawdbot-session-reset-fail-"),
);
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-reset-fail-"));
process.env.CLAWDBOT_STATE_DIR = stateDir;
const saveSpy = vi
.spyOn(sessions, "saveSessionStore")
.mockRejectedValueOnce(new Error("boom"));
const saveSpy = vi.spyOn(sessions, "saveSessionStore").mockRejectedValueOnce(new Error("boom"));
try {
const sessionId = "session-corrupt";
const storePath = path.join(stateDir, "sessions", "sessions.json");
@@ -185,9 +180,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
const payloads = Array.isArray(res) ? res : res ? [res] : [];
expect(payloads.length).toBe(1);
expect(payloads[0]?.text).toContain("LLM connection failed");
expect(payloads[0]?.text).toContain(
"socket connection was closed unexpectedly",
);
expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly");
expect(payloads[0]?.text).toContain("```");
});
});

View File

@@ -13,10 +13,7 @@ const runCliAgentMock = vi.fn();
type EmbeddedRunParams = {
prompt?: string;
extraSystemPrompt?: string;
onAgentEvent?: (evt: {
stream?: string;
data?: { phase?: string; willRetry?: boolean };
}) => void;
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
};
vi.mock("../../agents/model-fallback.js", () => ({
@@ -45,8 +42,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -140,21 +136,19 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
runEmbeddedPiAgentMock.mockImplementation(
async (params: EmbeddedRunParams) => {
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: false },
});
return { payloads: [], meta: {} };
}
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
},
);
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: false },
});
return { payloads: [], meta: {} };
}
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
});
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,

View File

@@ -13,10 +13,7 @@ const runCliAgentMock = vi.fn();
type EmbeddedRunParams = {
prompt?: string;
extraSystemPrompt?: string;
onAgentEvent?: (evt: {
stream?: string;
data?: { phase?: string; willRetry?: boolean };
}) => void;
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
};
vi.mock("../../agents/model-fallback.js", () => ({
@@ -45,8 +42,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -141,18 +137,16 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
const calls: Array<{ prompt?: string }> = [];
runEmbeddedPiAgentMock.mockImplementation(
async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
return { payloads: [], meta: {} };
}
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
},
);
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
return { payloads: [], meta: {} };
}
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
});
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,
@@ -184,10 +178,7 @@ describe("runReplyAgent memory flush", () => {
typingMode: "instant",
});
expect(calls.map((call) => call.prompt)).toEqual([
DEFAULT_MEMORY_FLUSH_PROMPT,
"hello",
]);
expect(calls.map((call) => call.prompt)).toEqual([DEFAULT_MEMORY_FLUSH_PROMPT, "hello"]);
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number");
@@ -207,12 +198,10 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
runEmbeddedPiAgentMock.mockImplementation(
async (_params: EmbeddedRunParams) => ({
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
}),
);
runEmbeddedPiAgentMock.mockImplementation(async (_params: EmbeddedRunParams) => ({
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
}));
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,
@@ -250,9 +239,7 @@ describe("runReplyAgent memory flush", () => {
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as
| { prompt?: string }
| undefined;
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined;
expect(call?.prompt).toBe("hello");
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));

View File

@@ -12,10 +12,7 @@ const runCliAgentMock = vi.fn();
type EmbeddedRunParams = {
prompt?: string;
extraSystemPrompt?: string;
onAgentEvent?: (evt: {
stream?: string;
data?: { phase?: string; willRetry?: boolean };
}) => void;
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
};
vi.mock("../../agents/model-fallback.js", () => ({
@@ -44,8 +41,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -141,15 +137,13 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
const calls: Array<{ prompt?: string }> = [];
runEmbeddedPiAgentMock.mockImplementation(
async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
},
);
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
});
runCliAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
@@ -187,9 +181,7 @@ describe("runReplyAgent memory flush", () => {
});
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
const call = runCliAgentMock.mock.calls[0]?.[0] as
| { prompt?: string }
| undefined;
const call = runCliAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined;
expect(call?.prompt).toBe("hello");
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});

View File

@@ -12,10 +12,7 @@ const runCliAgentMock = vi.fn();
type EmbeddedRunParams = {
prompt?: string;
extraSystemPrompt?: string;
onAgentEvent?: (evt: {
stream?: string;
data?: { phase?: string; willRetry?: boolean };
}) => void;
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
};
vi.mock("../../agents/model-fallback.js", () => ({
@@ -44,8 +41,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -140,15 +136,13 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
const calls: Array<{ prompt?: string }> = [];
runEmbeddedPiAgentMock.mockImplementation(
async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
},
);
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
});
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,
@@ -207,15 +201,13 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
const calls: Array<{ prompt?: string }> = [];
runEmbeddedPiAgentMock.mockImplementation(
async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
},
);
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
});
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,

View File

@@ -13,10 +13,7 @@ const runCliAgentMock = vi.fn();
type EmbeddedRunParams = {
prompt?: string;
extraSystemPrompt?: string;
onAgentEvent?: (evt: {
stream?: string;
data?: { phase?: string; willRetry?: boolean };
}) => void;
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
};
vi.mock("../../agents/model-fallback.js", () => ({
@@ -45,8 +42,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -141,18 +137,16 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
const calls: Array<EmbeddedRunParams> = [];
runEmbeddedPiAgentMock.mockImplementation(
async (params: EmbeddedRunParams) => {
calls.push(params);
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
return { payloads: [], meta: {} };
}
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
},
);
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
calls.push(params);
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
return { payloads: [], meta: {} };
}
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
});
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,
@@ -221,15 +215,13 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
const calls: Array<{ prompt?: string }> = [];
runEmbeddedPiAgentMock.mockImplementation(
async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
},
);
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
return {
payloads: [{ text: "ok" }],
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
};
});
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,

View File

@@ -28,8 +28,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -101,9 +100,7 @@ describe("runReplyAgent messaging tool suppression", () => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [
{ tool: "slack", provider: "slack", to: "channel:C1" },
],
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
meta: {},
});
@@ -116,9 +113,7 @@ describe("runReplyAgent messaging tool suppression", () => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [
{ tool: "discord", provider: "discord", to: "channel:C1" },
],
messagingToolSentTargets: [{ tool: "discord", provider: "discord", to: "channel:C1" }],
meta: {},
});

View File

@@ -23,8 +23,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
@@ -118,11 +117,7 @@ describe("runReplyAgent fallback reasoning tags", () => {
meta: {},
});
runWithModelFallbackMock.mockImplementationOnce(
async ({
run,
}: {
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("google-antigravity", "gemini-3"),
provider: "google-antigravity",
model: "gemini-3",
@@ -131,27 +126,19 @@ describe("runReplyAgent fallback reasoning tags", () => {
await createRun();
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as
| EmbeddedPiAgentParams
| undefined;
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as EmbeddedPiAgentParams | undefined;
expect(call?.enforceFinalTag).toBe(true);
});
it("enforces <final> during memory flush on fallback providers", async () => {
runEmbeddedPiAgentMock.mockImplementation(
async (params: EmbeddedPiAgentParams) => {
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
return { payloads: [], meta: {} };
}
return { payloads: [{ text: "ok" }], meta: {} };
},
);
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedPiAgentParams) => {
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
return { payloads: [], meta: {} };
}
return { payloads: [{ text: "ok" }], meta: {} };
});
runWithModelFallbackMock.mockImplementation(
async ({
run,
}: {
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("google-antigravity", "gemini-3"),
provider: "google-antigravity",
model: "gemini-3",
@@ -169,8 +156,7 @@ describe("runReplyAgent fallback reasoning tags", () => {
const flushCall = runEmbeddedPiAgentMock.mock.calls.find(
([params]) =>
(params as EmbeddedPiAgentParams | undefined)?.prompt ===
DEFAULT_MEMORY_FLUSH_PROMPT,
(params as EmbeddedPiAgentParams | undefined)?.prompt === DEFAULT_MEMORY_FLUSH_PROMPT,
)?.[0] as EmbeddedPiAgentParams | undefined;
expect(flushCall?.enforceFinalTag).toBe(true);

View File

@@ -29,25 +29,12 @@ import {
} from "./agent-runner-helpers.js";
import { runMemoryFlushIfNeeded } from "./agent-runner-memory.js";
import { buildReplyPayloads } from "./agent-runner-payloads.js";
import {
appendUsageLine,
formatResponseUsageLine,
} from "./agent-runner-utils.js";
import {
createAudioAsVoiceBuffer,
createBlockReplyPipeline,
} from "./block-reply-pipeline.js";
import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-utils.js";
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
import { createFollowupRunner } from "./followup-runner.js";
import {
enqueueFollowupRun,
type FollowupRun,
type QueueSettings,
} from "./queue.js";
import {
createReplyToModeFilterForChannel,
resolveReplyToMode,
} from "./reply-threading.js";
import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js";
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
import { incrementCompactionCount } from "./session-updates.js";
import type { TypingController } from "./typing.js";
import { createTypingSignaler } from "./typing-mode.js";
@@ -129,8 +116,7 @@ export async function runReplyAgent(params: {
});
const pendingToolTasks = new Set<Promise<void>>();
const blockReplyTimeoutMs =
opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
const blockReplyTimeoutMs = opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
const replyToChannel =
sessionCtx.OriginatingChannel ??
@@ -142,10 +128,7 @@ export async function runReplyAgent(params: {
replyToChannel,
sessionCtx.AccountId,
);
const applyReplyToMode = createReplyToModeFilterForChannel(
replyToMode,
replyToChannel,
);
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
const cfg = followupRun.run.config;
const blockReplyCoalescing =
blockStreamingEnabled && opts?.onBlockReply
@@ -167,10 +150,7 @@ export async function runReplyAgent(params: {
: null;
if (shouldSteer && isStreaming) {
const steered = queueEmbeddedPiMessage(
followupRun.run.sessionId,
followupRun.prompt,
);
const steered = queueEmbeddedPiMessage(followupRun.run.sessionId, followupRun.prompt);
if (steered && !shouldFollowup) {
if (activeSessionEntry && activeSessionStore && sessionKey) {
activeSessionEntry.updatedAt = Date.now();
@@ -225,9 +205,7 @@ export async function runReplyAgent(params: {
});
let responseUsageLine: string | undefined;
const resetSessionAfterCompactionFailure = async (
reason: string,
): Promise<boolean> => {
const resetSessionAfterCompactionFailure = async (reason: string): Promise<boolean> => {
if (!sessionKey || !activeSessionStore || !storePath) return false;
const nextSessionId = crypto.randomUUID();
const nextEntry: SessionEntry = {
@@ -239,14 +217,8 @@ export async function runReplyAgent(params: {
};
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const topicId =
typeof sessionCtx.MessageThreadId === "number"
? sessionCtx.MessageThreadId
: undefined;
const nextSessionFile = resolveSessionTranscriptPath(
nextSessionId,
agentId,
topicId,
);
typeof sessionCtx.MessageThreadId === "number" ? sessionCtx.MessageThreadId : undefined;
const nextSessionFile = resolveSessionTranscriptPath(nextSessionId, agentId, topicId);
nextEntry.sessionFile = nextSessionFile;
activeSessionStore[sessionKey] = nextEntry;
try {
@@ -289,11 +261,7 @@ export async function runReplyAgent(params: {
});
if (runOutcome.kind === "final") {
return finalizeWithFollowup(
runOutcome.payload,
queueKey,
runFollowupTurn,
);
return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn);
}
const { runResult, fallbackProvider, fallbackModel } = runOutcome;
@@ -354,12 +322,9 @@ export async function runReplyAgent(params: {
await signalTypingIfNeeded(replyPayloads, typingSignals);
const usage = runResult.meta.agentMeta?.usage;
const modelUsed =
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta.agentMeta?.provider ??
fallbackProvider ??
followupRun.run.provider;
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
const cliSessionId = isCliProvider(providerUsed, cfg)
? runResult.meta.agentMeta?.sessionId?.trim()
: undefined;
@@ -378,13 +343,11 @@ export async function runReplyAgent(params: {
update: async (entry) => {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens =
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
const patch: Partial<SessionEntry> = {
inputTokens: input,
outputTokens: output,
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
totalTokens: promptTokens > 0 ? promptTokens : (usage.total ?? input),
modelProvider: providerUsed,
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
@@ -437,9 +400,7 @@ export async function runReplyAgent(params: {
const responseUsageEnabled =
(activeSessionEntry?.responseUsage ??
(sessionKey
? activeSessionStore?.[sessionKey]?.responseUsage
: undefined)) === "on";
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined)) === "on";
if (responseUsageEnabled && hasNonzeroUsage(usage)) {
const authMode = resolveModelAuthMode(providerUsed, cfg);
const showCost = authMode === "api-key";
@@ -469,17 +430,11 @@ export async function runReplyAgent(params: {
});
if (resolvedVerboseLevel === "on") {
const suffix = typeof count === "number" ? ` (count ${count})` : "";
finalPayloads = [
{ text: `🧹 Auto-compaction complete${suffix}.` },
...finalPayloads,
];
finalPayloads = [{ text: `🧹 Auto-compaction complete${suffix}.` }, ...finalPayloads];
}
}
if (resolvedVerboseLevel === "on" && activeIsNewSession) {
finalPayloads = [
{ text: `🧭 New session: ${followupRun.run.sessionId}` },
...finalPayloads,
];
finalPayloads = [{ text: `🧭 New session: ${followupRun.run.sessionId}` }, ...finalPayloads];
}
if (responseUsageLine) {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);

View File

@@ -1,9 +1,5 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import {
getFinishedSession,
getSession,
markExited,
} from "../../agents/bash-process-registry.js";
import { getFinishedSession, getSession, markExited } from "../../agents/bash-process-registry.js";
import { createExecTool } from "../../agents/bash-tools.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import { killProcessTree } from "../../agents/shell-utils.js";
@@ -41,8 +37,7 @@ function clampNumber(value: number, min: number, max: number) {
function resolveForegroundMs(cfg: ClawdbotConfig): number {
const raw = cfg.commands?.bashForegroundMs;
if (typeof raw !== "number" || Number.isNaN(raw))
return DEFAULT_FOREGROUND_MS;
if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS;
return clampNumber(Math.floor(raw), 0, MAX_FOREGROUND_MS);
}
@@ -98,8 +93,7 @@ function resolveRawCommandBody(params: {
agentId?: string;
isGroup: boolean;
}) {
const source =
params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body ?? "";
const source = params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body ?? "";
const stripped = stripStructuralPrefixes(source);
return params.isGroup
? stripMentions(stripped, params.ctx, params.cfg, params.agentId)
@@ -110,8 +104,7 @@ function getScopedSession(sessionId: string) {
const running = getSession(sessionId);
if (running && running.scopeKey === CHAT_BASH_SCOPE_KEY) return { running };
const finished = getFinishedSession(sessionId);
if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY)
return { finished };
if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY) return { finished };
return {};
}
@@ -165,11 +158,7 @@ function formatElevatedUnavailableMessage(params: {
`elevated is not available right now (runtime=${params.runtimeSandboxed ? "sandboxed" : "direct"}).`,
);
if (params.failures.length > 0) {
lines.push(
`Failing gates: ${params.failures
.map((f) => `${f.gate} (${f.key})`)
.join(", ")}`,
);
lines.push(`Failing gates: ${params.failures.map((f) => `${f.gate} (${f.key})`).join(", ")}`);
} else {
lines.push(
"Failing gates: enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled), allowFrom (tools.elevated.allowFrom.<provider>).",
@@ -244,18 +233,14 @@ export async function handleBashChatCommand(params: {
if (request.action === "poll") {
const sessionId =
request.sessionId?.trim() ||
(liveJob?.state === "running" ? liveJob.sessionId : "");
request.sessionId?.trim() || (liveJob?.state === "running" ? liveJob.sessionId : "");
if (!sessionId) {
return { text: "⚙️ No active bash job." };
}
const { running, finished } = getScopedSession(sessionId);
if (running) {
attachActiveWatcher(sessionId);
const runtimeSec = Math.max(
0,
Math.floor((Date.now() - running.startedAt) / 1000),
);
const runtimeSec = Math.max(0, Math.floor((Date.now() - running.startedAt) / 1000));
const tail = running.tail || "(no output yet)";
return {
text: [
@@ -291,8 +276,7 @@ export async function handleBashChatCommand(params: {
if (request.action === "stop") {
const sessionId =
request.sessionId?.trim() ||
(liveJob?.state === "running" ? liveJob.sessionId : "");
request.sessionId?.trim() || (liveJob?.state === "running" ? liveJob.sessionId : "");
if (!sessionId) {
return { text: "⚙️ No active bash job." };
}
@@ -326,9 +310,7 @@ export async function handleBashChatCommand(params: {
// request.action === "run"
if (liveJob) {
const label =
liveJob.state === "running"
? formatSessionSnippet(liveJob.sessionId)
: "starting";
liveJob.state === "running" ? formatSessionSnippet(liveJob.sessionId) : "starting";
return {
text: `⚠️ A bash job is already running (${label}). Use !poll / !stop (or /bash poll / /bash stop).`,
};
@@ -346,8 +328,7 @@ export async function handleBashChatCommand(params: {
try {
const foregroundMs = resolveForegroundMs(params.cfg);
const shouldBackgroundImmediately = foregroundMs <= 0;
const timeoutSec =
params.cfg.tools?.exec?.timeoutSec ?? params.cfg.tools?.bash?.timeoutSec;
const timeoutSec = params.cfg.tools?.exec?.timeoutSec ?? params.cfg.tools?.bash?.timeoutSec;
const execTool = createExecTool({
scopeKey: CHAT_BASH_SCOPE_KEY,
allowBackground: true,
@@ -385,14 +366,11 @@ export async function handleBashChatCommand(params: {
// Completed in foreground.
activeJob = null;
const exitCode =
result.details?.status === "completed" ? result.details.exitCode : 0;
const exitCode = result.details?.status === "completed" ? result.details.exitCode : 0;
const output =
result.details?.status === "completed"
? result.details.aggregated
: result.content
.map((chunk) => (chunk.type === "text" ? chunk.text : ""))
.join("\n");
: result.content.map((chunk) => (chunk.type === "text" ? chunk.text : "")).join("\n");
return {
text: [
`⚙️ bash: ${commandText}`,
@@ -404,9 +382,7 @@ export async function handleBashChatCommand(params: {
activeJob = null;
const message = err instanceof Error ? err.message : String(err);
return {
text: [`⚠️ bash failed: ${commandText}`, formatOutputBlock(message)].join(
"\n",
),
text: [`⚠️ bash failed: ${commandText}`, formatOutputBlock(message)].join("\n"),
};
}
}

View File

@@ -66,8 +66,7 @@ export function createBlockReplyCoalescer(params: {
const enqueue = (payload: ReplyPayload) => {
if (shouldAbort()) return;
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const text = payload.text ?? "";
const hasText = text.trim().length > 0;
if (hasMedia) {
@@ -79,8 +78,7 @@ export function createBlockReplyCoalescer(params: {
if (
bufferText &&
(bufferReplyToId !== payload.replyToId ||
bufferAudioAsVoice !== payload.audioAsVoice)
(bufferReplyToId !== payload.replyToId || bufferAudioAsVoice !== payload.audioAsVoice)
) {
void flush({ force: true });
}

View File

@@ -30,8 +30,7 @@ export function createAudioAsVoiceBuffer(params: {
}
},
shouldBuffer: (payload) => params.isAudioPayload(payload),
finalize: (payload) =>
seenAudioAsVoice ? { ...payload, audioAsVoice: true } : payload,
finalize: (payload) => (seenAudioAsVoice ? { ...payload, audioAsVoice: true } : payload),
};
}
@@ -97,9 +96,7 @@ export function createBlockReplyPipeline(params: {
if (sentKeys.has(payloadKey) || pendingKeys.has(payloadKey)) return;
pendingKeys.add(payloadKey);
const timeoutError = new Error(
`block reply delivery timed out after ${timeoutMs}ms`,
);
const timeoutError = new Error(`block reply delivery timed out after ${timeoutMs}ms`);
const abortController = new AbortController();
sendChain = sendChain
.then(async () => {
@@ -180,8 +177,7 @@ export function createBlockReplyPipeline(params: {
const enqueue = (payload: ReplyPayload) => {
if (aborted) return;
if (bufferPayload(payload)) return;
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
if (hasMedia) {
void coalescer?.flush({ force: true });
sendPayload(payload);
@@ -189,11 +185,7 @@ export function createBlockReplyPipeline(params: {
}
if (coalescer) {
const payloadKey = createBlockReplyPayloadKey(payload);
if (
seenKeys.has(payloadKey) ||
pendingKeys.has(payloadKey) ||
bufferedKeys.has(payloadKey)
) {
if (seenKeys.has(payloadKey) || pendingKeys.has(payloadKey) || bufferedKeys.has(payloadKey)) {
return;
}
bufferedKeys.add(payloadKey);
@@ -217,8 +209,7 @@ export function createBlockReplyPipeline(params: {
enqueue,
flush,
stop,
hasBuffered: () =>
Boolean(coalescer?.hasBuffered() || bufferedPayloads.length > 0),
hasBuffered: () => Boolean(coalescer?.hasBuffered() || bufferedPayloads.length > 0),
didStream: () => didStream,
isAborted: () => aborted,
hasSentPayload: (payload) => {

View File

@@ -14,9 +14,7 @@ const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
INTERNAL_MESSAGE_CHANNEL,
]);
function normalizeChunkProvider(
provider?: string,
): TextChunkProvider | undefined {
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
if (!provider) return undefined;
const cleaned = provider.trim().toLowerCase();
return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider)
@@ -26,10 +24,7 @@ function normalizeChunkProvider(
type ProviderBlockStreamingConfig = {
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
accounts?: Record<
string,
{ blockStreamingCoalesce?: BlockStreamingCoalesceConfig }
>;
accounts?: Record<string, { blockStreamingCoalesce?: BlockStreamingCoalesceConfig }>;
};
function resolveProviderBlockStreamingCoalesce(params: {
@@ -72,20 +67,13 @@ export function resolveBlockStreamingChunking(
fallbackLimit: providerChunkLimit,
});
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
const maxRequested = Math.max(
1,
Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX),
);
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
const minFallback = DEFAULT_BLOCK_STREAM_MIN;
const minRequested = Math.max(
1,
Math.floor(chunkCfg?.minChars ?? minFallback),
);
const minRequested = Math.max(1, Math.floor(chunkCfg?.minChars ?? minFallback));
const minChars = Math.min(minRequested, maxChars);
const breakPreference =
chunkCfg?.breakPreference === "newline" ||
chunkCfg?.breakPreference === "sentence"
chunkCfg?.breakPreference === "newline" || chunkCfg?.breakPreference === "sentence"
? chunkCfg.breakPreference
: "paragraph";
return { minChars, maxChars, breakPreference };
@@ -117,8 +105,7 @@ export function resolveBlockStreamingCoalescing(
providerKey,
accountId,
});
const coalesceCfg =
providerCfg ?? cfg?.agents?.defaults?.blockStreamingCoalesce;
const coalesceCfg = providerCfg ?? cfg?.agents?.defaults?.blockStreamingCoalesce;
const minRequested = Math.max(
1,
Math.floor(
@@ -128,23 +115,17 @@ export function resolveBlockStreamingCoalescing(
DEFAULT_BLOCK_STREAM_MIN,
),
);
const maxRequested = Math.max(
1,
Math.floor(coalesceCfg?.maxChars ?? textLimit),
);
const maxRequested = Math.max(1, Math.floor(coalesceCfg?.maxChars ?? textLimit));
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
const minChars = Math.min(minRequested, maxChars);
const idleMs = Math.max(
0,
Math.floor(
coalesceCfg?.idleMs ??
providerDefaults?.idleMs ??
DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS,
coalesceCfg?.idleMs ?? providerDefaults?.idleMs ?? DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS,
),
);
const preference = chunking?.breakPreference ?? "paragraph";
const joiner =
preference === "sentence" ? " " : preference === "newline" ? "\n" : "\n\n";
const joiner = preference === "sentence" ? " " : preference === "newline" ? "\n" : "\n\n";
return {
minChars,
maxChars,

View File

@@ -30,9 +30,7 @@ export async function applySessionHints(params: {
}
}
const messageIdHint = params.messageId?.trim()
? `[message_id: ${params.messageId.trim()}]`
: "";
const messageIdHint = params.messageId?.trim() ? `[message_id: ${params.messageId.trim()}]` : "";
if (messageIdHint) {
prefixedBodyBase = `${prefixedBodyBase}\n${messageIdHint}`;
}

View File

@@ -2,26 +2,17 @@ import { logVerbose } from "../../globals.js";
import { handleBashChatCommand } from "./bash-command.js";
import type { CommandHandler } from "./commands-types.js";
export const handleBashCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleBashCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const { command } = params;
const bashSlashRequested =
command.commandBodyNormalized === "/bash" ||
command.commandBodyNormalized.startsWith("/bash ");
command.commandBodyNormalized === "/bash" || command.commandBodyNormalized.startsWith("/bash ");
const bashBangRequested = command.commandBodyNormalized.startsWith("!");
if (
!bashSlashRequested &&
!(bashBangRequested && command.isAuthorizedSender)
) {
if (!bashSlashRequested && !(bashBangRequested && command.isAuthorizedSender)) {
return null;
}
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /bash from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
logVerbose(`Ignoring /bash from unauthorized sender: ${command.senderId || "<unknown>"}`);
return { shouldContinue: false };
}
const reply = await handleBashChatCommand({

View File

@@ -73,24 +73,19 @@ export const handleCompactCommand: CommandHandler = async (params) => {
skillsSnapshot: params.sessionEntry.skillsSnapshot,
provider: params.provider,
model: params.model,
thinkLevel:
params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()),
thinkLevel: params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()),
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
customInstructions,
ownerNumbers:
params.command.ownerList.length > 0
? params.command.ownerList
: undefined,
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
});
const totalTokens =
params.sessionEntry.totalTokens ??
(params.sessionEntry.inputTokens ?? 0) +
(params.sessionEntry.outputTokens ?? 0);
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
totalTokens > 0 ? totalTokens : null,
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,

View File

@@ -20,14 +20,9 @@ import type { CommandHandler } from "./commands-types.js";
import { parseConfigCommand } from "./config-commands.js";
import { parseDebugCommand } from "./debug-commands.js";
export const handleConfigCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleConfigCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const configCommand = parseConfigCommand(
params.command.commandBodyNormalized,
);
const configCommand = parseConfigCommand(params.command.commandBodyNormalized);
if (!configCommand) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
@@ -50,11 +45,7 @@ export const handleConfigCommand: CommandHandler = async (
};
}
const snapshot = await readConfigFileSnapshot();
if (
!snapshot.valid ||
!snapshot.parsed ||
typeof snapshot.parsed !== "object"
) {
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
return {
shouldContinue: false,
reply: {
@@ -62,9 +53,7 @@ export const handleConfigCommand: CommandHandler = async (
},
};
}
const parsedBase = structuredClone(
snapshot.parsed as Record<string, unknown>,
);
const parsedBase = structuredClone(snapshot.parsed as Record<string, unknown>);
if (configCommand.action === "show") {
const pathRaw = configCommand.path?.trim();
@@ -159,10 +148,7 @@ export const handleConfigCommand: CommandHandler = async (
return null;
};
export const handleDebugCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleDebugCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const debugCommand = parseDebugCommand(params.command.commandBodyNormalized);
if (!debugCommand) return null;

View File

@@ -14,8 +14,7 @@ export function buildCommandContext(params: {
triggerBodyNormalized: string;
commandAuthorized: boolean;
}): CommandContext {
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } =
params;
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } = params;
const auth = resolveCommandAuthorization({
ctx,
cfg,
@@ -23,13 +22,10 @@ export function buildCommandContext(params: {
});
const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase();
const channel = (ctx.Provider ?? surface).trim().toLowerCase();
const abortKey =
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const abortKey = sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = normalizeCommandBody(
isGroup
? stripMentions(rawBodyNormalized, ctx, cfg, agentId)
: rawBodyNormalized,
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized,
);
return {

View File

@@ -39,9 +39,7 @@ const HANDLERS: CommandHandler[] = [
handleAbortTrigger,
];
export async function handleCommands(
params: HandleCommandsParams,
): Promise<CommandHandlerResult> {
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
const resetRequested =
params.command.commandBodyNormalized === "/reset" ||
params.command.commandBodyNormalized === "/new";
@@ -71,9 +69,7 @@ export async function handleCommands(
chatType: params.sessionEntry?.chatType,
});
if (sendPolicy === "deny") {
logVerbose(
`Send blocked by policy for session ${params.sessionKey ?? "unknown"}`,
);
logVerbose(`Send blocked by policy for session ${params.sessionKey ?? "unknown"}`);
return { shouldContinue: false };
}

View File

@@ -3,10 +3,7 @@ import { buildCommandsMessage, buildHelpMessage } from "../status.js";
import { buildStatusReply } from "./commands-status.js";
import type { CommandHandler } from "./commands-types.js";
export const handleHelpCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleHelpCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
if (params.command.commandBodyNormalized !== "/help") return null;
if (!params.command.isAuthorizedSender) {
@@ -21,10 +18,7 @@ export const handleHelpCommand: CommandHandler = async (
};
};
export const handleCommandsListCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleCommandsListCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
if (params.command.commandBodyNormalized !== "/commands") return null;
if (!params.command.isAuthorizedSender) {
@@ -39,14 +33,10 @@ export const handleCommandsListCommand: CommandHandler = async (
};
};
export const handleStatusCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const statusRequested =
params.directives.hasStatusDirective ||
params.command.commandBodyNormalized === "/status";
params.directives.hasStatusDirective || params.command.commandBodyNormalized === "/status";
if (!statusRequested) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
@@ -74,10 +64,7 @@ export const handleStatusCommand: CommandHandler = async (
return { shouldContinue: false, reply };
};
export const handleWhoamiCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleWhoamiCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
if (params.command.commandBodyNormalized !== "/whoami") return null;
if (!params.command.isAuthorizedSender) {
@@ -91,9 +78,7 @@ export const handleWhoamiCommand: CommandHandler = async (
const lines = ["🧭 Identity", `Channel: ${params.command.channel}`];
if (senderId) lines.push(`User id: ${senderId}`);
if (senderUsername) {
const handle = senderUsername.startsWith("@")
? senderUsername
: `@${senderUsername}`;
const handle = senderUsername.startsWith("@") ? senderUsername : `@${senderUsername}`;
lines.push(`Username: ${handle}`);
}
if (params.ctx.ChatType === "group" && params.ctx.From) {

View File

@@ -2,10 +2,7 @@ import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import type { SessionEntry } from "../../config/sessions.js";
import { saveSessionStore } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import {
scheduleGatewaySigusr1Restart,
triggerClawdbotRestart,
} from "../../infra/restart.js";
import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { parseActivationCommand } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
@@ -33,12 +30,8 @@ function resolveAbortTarget(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
}) {
const targetSessionKey =
params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
const { entry, key } = resolveSessionEntryForKey(
params.sessionStore,
targetSessionKey,
);
const targetSessionKey = params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
const { entry, key } = resolveSessionEntryForKey(params.sessionStore, targetSessionKey);
if (entry && key) return { entry, key, sessionId: entry.sessionId };
if (params.sessionEntry && params.sessionKey) {
return {
@@ -50,14 +43,9 @@ function resolveAbortTarget(params: {
return { entry: undefined, key: targetSessionKey, sessionId: undefined };
}
export const handleActivationCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleActivationCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const activationCommand = parseActivationCommand(
params.command.commandBodyNormalized,
);
const activationCommand = parseActivationCommand(params.command.commandBodyNormalized);
if (!activationCommand.hasCommand) return null;
if (!params.isGroup) {
return {
@@ -94,14 +82,9 @@ export const handleActivationCommand: CommandHandler = async (
};
};
export const handleSendPolicyCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleSendPolicyCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const sendPolicyCommand = parseSendPolicyCommand(
params.command.commandBodyNormalized,
);
const sendPolicyCommand = parseSendPolicyCommand(params.command.commandBodyNormalized);
if (!sendPolicyCommand.hasCommand) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
@@ -139,10 +122,7 @@ export const handleSendPolicyCommand: CommandHandler = async (
};
};
export const handleRestartCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
if (params.command.commandBodyNormalized !== "/restart") return null;
if (!params.command.isAuthorizedSender) {
@@ -171,9 +151,7 @@ export const handleRestartCommand: CommandHandler = async (
}
const restartMethod = triggerClawdbotRestart();
if (!restartMethod.ok) {
const detail = restartMethod.detail
? ` Details: ${restartMethod.detail}`
: "";
const detail = restartMethod.detail ? ` Details: ${restartMethod.detail}` : "";
return {
shouldContinue: false,
reply: {
@@ -189,10 +167,7 @@ export const handleRestartCommand: CommandHandler = async (
};
};
export const handleStopCommand: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleStopCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
if (params.command.commandBodyNormalized !== "/stop") return null;
if (!params.command.isAuthorizedSender) {
@@ -223,10 +198,7 @@ export const handleStopCommand: CommandHandler = async (
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
};
export const handleAbortTrigger: CommandHandler = async (
params,
allowTextCommands,
) => {
export const handleAbortTrigger: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
if (!isAbortTrigger(params.command.rawBodyNormalized)) return null;
const abortTarget = resolveAbortTarget({

View File

@@ -8,10 +8,7 @@ import {
resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
@@ -23,12 +20,7 @@ import {
} from "../../infra/provider-usage.js";
import { normalizeGroupActivation } from "../group-activation.js";
import { buildStatusMessage } from "../status.js";
import type {
ElevatedLevel,
ReasoningLevel,
ThinkLevel,
VerboseLevel,
} from "../thinking.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import type { CommandContext } from "./commands-types.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
@@ -132,9 +124,7 @@ export async function buildStatusReply(params: {
defaultGroupActivation,
} = params;
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /status from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
logVerbose(`Ignoring /status from unauthorized sender: ${command.senderId || "<unknown>"}`);
return undefined;
}
const statusAgentId = sessionKey
@@ -151,10 +141,7 @@ export async function buildStatusReply(params: {
agentDir: statusAgentDir,
});
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
if (
!usageLine &&
(resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on")
) {
if (!usageLine && (resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on")) {
const entry = usageSummary.providers[0];
if (entry?.error) {
usageLine = `📊 Usage: ${entry.displayName} (${entry.error})`;
@@ -172,13 +159,10 @@ export async function buildStatusReply(params: {
const queueKey = sessionKey ?? sessionEntry?.sessionId;
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
const queueOverrides = Boolean(
sessionEntry?.queueDebounceMs ??
sessionEntry?.queueCap ??
sessionEntry?.queueDrop,
sessionEntry?.queueDebounceMs ?? sessionEntry?.queueCap ?? sessionEntry?.queueDrop,
);
const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation())
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
: undefined;
const agentDefaults = cfg.agents?.defaults ?? {};
const statusText = buildStatusMessage({
@@ -202,12 +186,7 @@ export async function buildStatusReply(params: {
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(
provider,
cfg,
sessionEntry,
statusAgentDir,
),
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry, statusAgentDir),
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,

View File

@@ -2,12 +2,7 @@ import type { ChannelId } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
import type { MsgContext } from "../templating.js";
import type {
ElevatedLevel,
ReasoningLevel,
ThinkLevel,
VerboseLevel,
} from "../thinking.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import type { InlineDirectives } from "./directive-handling.js";

View File

@@ -6,11 +6,7 @@ import { resetBashChatCommandForTests } from "./bash-command.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
function buildParams(
commandBody: string,
cfg: ClawdbotConfig,
ctxOverrides?: Partial<MsgContext>,
) {
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
@@ -71,9 +67,7 @@ describe("handleCommands gating", () => {
params.elevated = {
enabled: true,
allowed: false,
failures: [
{ gate: "allowFrom", key: "tools.elevated.allowFrom.whatsapp" },
],
failures: [{ gate: "allowFrom", key: "tools.elevated.allowFrom.whatsapp" }],
};
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);

View File

@@ -23,8 +23,7 @@ export function parseConfigCommand(raw: string): ConfigCommand | null {
case "get":
return { action: "show", path: args || undefined };
case "unset": {
if (!args)
return { action: "error", message: "Usage: /config unset path" };
if (!args) return { action: "error", message: "Usage: /config unset path" };
return { action: "unset", path: args };
}
case "set": {

View File

@@ -24,8 +24,7 @@ export function parseDebugCommand(raw: string): DebugCommand | null {
case "reset":
return { action: "reset" };
case "unset": {
if (!args)
return { action: "error", message: "Usage: /debug unset path" };
if (!args) return { action: "error", message: "Usage: /debug unset path" };
return { action: "unset", path: args };
}
case "set": {

View File

@@ -65,8 +65,7 @@ export const resolveAuthLabel = async (
const configProfile = cfg.auth?.profiles?.[profileId];
const missing =
!profile ||
(configProfile?.provider &&
configProfile.provider !== profile.provider) ||
(configProfile?.provider && configProfile.provider !== profile.provider) ||
(configProfile?.mode &&
configProfile.mode !== profile.type &&
!(configProfile.mode === "oauth" && profile.type === "token"));
@@ -115,11 +114,7 @@ export const resolveAuthLabel = async (
if (lastGood && profileId === lastGood) flags.push("lastGood");
if (isProfileInCooldown(store, profileId)) {
const until = store.usageStats?.[profileId]?.cooldownUntil;
if (
typeof until === "number" &&
Number.isFinite(until) &&
until > now
) {
if (typeof until === "number" && Number.isFinite(until) && until > now) {
flags.push(`cooldown ${formatUntil(until)}`);
} else {
flags.push("cooldown");
@@ -127,8 +122,7 @@ export const resolveAuthLabel = async (
}
if (
!profile ||
(configProfile?.provider &&
configProfile.provider !== profile.provider) ||
(configProfile?.provider && configProfile.provider !== profile.provider) ||
(configProfile?.mode &&
configProfile.mode !== profile.type &&
!(configProfile.mode === "oauth" && profile.type === "token"))
@@ -146,11 +140,7 @@ export const resolveAuthLabel = async (
Number.isFinite(profile.expires) &&
profile.expires > 0
) {
flags.push(
profile.expires <= now
? "expired"
: `exp ${formatUntil(profile.expires)}`,
);
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
}
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`;
@@ -171,11 +161,7 @@ export const resolveAuthLabel = async (
Number.isFinite(profile.expires) &&
profile.expires > 0
) {
flags.push(
profile.expires <= now
? "expired"
: `exp ${formatUntil(profile.expires)}`,
);
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
}
const suffixLabel = suffix ? ` ${suffix}` : "";
const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : "";
@@ -199,8 +185,7 @@ export const resolveAuthLabel = async (
if (customKey) {
return {
label: maskApiKey(customKey),
source:
mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "",
source: mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "",
};
}
return { label: "missing", source: "missing" };

View File

@@ -6,12 +6,7 @@ import type { ReplyPayload } from "../types.js";
import { handleDirectiveOnly } from "./directive-handling.impl.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import { isDirectiveOnly } from "./directive-handling.parse.js";
import type {
ElevatedLevel,
ReasoningLevel,
ThinkLevel,
VerboseLevel,
} from "./directives.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
export async function applyInlineDirectivesFastLane(params: {
directives: InlineDirectives;
@@ -45,9 +40,7 @@ export async function applyInlineDirectivesFastLane(params: {
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
allowedModelKeys: Set<string>;
allowedModelCatalog: Awaited<
ReturnType<
typeof import("../../agents/model-catalog.js").loadModelCatalog
>
ReturnType<typeof import("../../agents/model-catalog.js").loadModelCatalog>
>;
resetModelOverride: boolean;
};

View File

@@ -1,18 +1,11 @@
import {
resolveAgentDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import {
formatThinkingLevels,
formatXHighModelHint,
supportsXHighThinking,
} from "../thinking.js";
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import {
maybeHandleModelDirectiveInfo,
@@ -28,12 +21,7 @@ import {
formatReasoningEvent,
withOptions,
} from "./directive-handling.shared.js";
import type {
ElevatedLevel,
ReasoningLevel,
ThinkLevel,
VerboseLevel,
} from "./directives.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig;
@@ -95,8 +83,7 @@ export async function handleDirectiveOnly(params: {
cfg: params.cfg,
sessionKey: params.sessionKey,
}).sandboxed;
const shouldHintDirectRuntime =
directives.hasElevatedDirective && !runtimeIsSandboxed;
const shouldHintDirectRuntime = directives.hasElevatedDirective && !runtimeIsSandboxed;
const modelInfo = await maybeHandleModelDirectiveInfo({
directives,
@@ -161,10 +148,7 @@ export async function handleDirectiveOnly(params: {
if (!directives.rawReasoningLevel) {
const level = currentReasoningLevel ?? "off";
return {
text: withOptions(
`Current reasoning level: ${level}.`,
"on, off, stream",
),
text: withOptions(`Current reasoning level: ${level}.`, "on, off, stream"),
};
}
return {
@@ -196,10 +180,7 @@ export async function handleDirectiveOnly(params: {
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
};
}
if (
directives.hasElevatedDirective &&
(!elevatedEnabled || !elevatedAllowed)
) {
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
return {
text: formatElevatedUnavailableText({
runtimeSandboxed: runtimeIsSandboxed,
@@ -229,8 +210,7 @@ export async function handleDirectiveOnly(params: {
const nextThinkLevel = directives.hasThinkDirective
? directives.thinkLevel
: ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
currentThinkLevel);
: ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? currentThinkLevel);
const shouldDowngradeXHigh =
!directives.hasThinkDirective &&
nextThinkLevel === "xhigh" &&
@@ -242,17 +222,14 @@ export async function handleDirectiveOnly(params: {
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
const prevReasoningLevel =
currentReasoningLevel ??
(sessionEntry.reasoningLevel as ReasoningLevel | undefined) ??
"off";
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
let elevatedChanged =
directives.hasElevatedDirective &&
directives.elevatedLevel !== undefined &&
elevatedEnabled &&
elevatedAllowed;
let reasoningChanged =
directives.hasReasoningDirective &&
directives.reasoningLevel !== undefined;
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel;
@@ -264,12 +241,10 @@ export async function handleDirectiveOnly(params: {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off")
delete sessionEntry.reasoningLevel;
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel &&
directives.reasoningLevel !== undefined;
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
@@ -277,8 +252,7 @@ export async function handleDirectiveOnly(params: {
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel &&
directives.elevatedLevel !== undefined);
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (modelSelection) {
if (modelSelection.isDefault) {
@@ -319,26 +293,21 @@ export async function handleDirectiveOnly(params: {
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelSelection.alias),
{
sessionKey,
contextKey: `model:${nextLabel}`,
},
);
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
sessionKey,
contextKey: `model:${nextLabel}`,
});
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ??
"off") as ElevatedLevel;
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ??
"off") as ReasoningLevel;
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
@@ -385,9 +354,7 @@ export async function handleDirectiveOnly(params: {
}
if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias
? `${modelSelection.alias} (${label})`
: label;
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
parts.push(
modelSelection.isDefault
? `Model reset to default (${labelWithAlias}).`
@@ -398,27 +365,18 @@ export async function handleDirectiveOnly(params: {
}
}
if (directives.hasQueueDirective && directives.queueMode) {
parts.push(
formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`),
);
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
} else if (directives.hasQueueDirective && directives.queueReset) {
parts.push(formatDirectiveAck("Queue mode reset to default."));
}
if (
directives.hasQueueDirective &&
typeof directives.debounceMs === "number"
) {
parts.push(
formatDirectiveAck(`Queue debounce set to ${directives.debounceMs}ms.`),
);
if (directives.hasQueueDirective && typeof directives.debounceMs === "number") {
parts.push(formatDirectiveAck(`Queue debounce set to ${directives.debounceMs}ms.`));
}
if (directives.hasQueueDirective && typeof directives.cap === "number") {
parts.push(formatDirectiveAck(`Queue cap set to ${directives.cap}.`));
}
if (directives.hasQueueDirective && directives.dropPolicy) {
parts.push(
formatDirectiveAck(`Queue drop set to ${directives.dropPolicy}.`),
);
parts.push(formatDirectiveAck(`Queue drop set to ${directives.dropPolicy}.`));
}
const ack = parts.join(" ").trim();
if (!ack && directives.hasStatusDirective) return undefined;

View File

@@ -52,9 +52,7 @@ function sortProvidersForPicker(providers: string[]): string[] {
});
}
export function buildModelPickerItems(
catalog: ModelPickerCatalogEntry[],
): ModelPickerItem[] {
export function buildModelPickerItems(catalog: ModelPickerCatalogEntry[]): ModelPickerItem[] {
const byModel = new Map<string, { providerModels: Record<string, string> }>();
for (const entry of catalog) {
const provider = normalizeProviderId(entry.provider);
@@ -72,9 +70,7 @@ export function buildModelPickerItems(
const providers = sortProvidersForPicker(Object.keys(data.providerModels));
out.push({ model, providers, providerModels: data.providerModels });
}
out.sort((a, b) =>
a.model.toLowerCase().localeCompare(b.model.toLowerCase()),
);
out.sort((a, b) => a.model.toLowerCase().localeCompare(b.model.toLowerCase()));
return out;
}

View File

@@ -22,10 +22,7 @@ import {
resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import {
type ModelDirectiveSelection,
resolveModelDirectiveSelection,
} from "./model-selection.js";
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
function buildModelPickerCatalog(params: {
cfg: ClawdbotConfig;
@@ -127,10 +124,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
const items = buildModelPickerItems(pickerCatalog);
if (items.length === 0) return { text: "No models available." };
const current = `${params.provider}/${params.model}`;
const lines: string[] = [
`Current: ${current}`,
"Pick: /model <#> or /model <provider/model>",
];
const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"];
for (const [idx, item] of items.entries()) {
lines.push(`${idx + 1}) ${item.model}${item.providers.join(", ")}`);
}
@@ -194,8 +188,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
for (const entry of models) {
const label = `${provider}/${entry.id}`;
const aliases = params.aliasIndex.byKey.get(label);
const aliasSuffix =
aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
const aliasSuffix = aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
lines.push(`${label}${aliasSuffix}`);
}
}
@@ -217,10 +210,7 @@ export function resolveModelSelectionFromDirective(params: {
profileOverride?: string;
errorText?: string;
} {
if (
!params.directives.hasModelDirective ||
!params.directives.rawModelDirective
) {
if (!params.directives.hasModelDirective || !params.directives.rawModelDirective) {
if (params.directives.rawModelProfile) {
return { errorText: "Auth profile override requires a model selection." };
}
@@ -261,9 +251,7 @@ export function resolveModelSelectionFromDirective(params: {
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault:
picked.provider === params.defaultProvider &&
picked.model === params.defaultModel,
isDefault: picked.provider === params.defaultProvider && picked.model === params.defaultModel,
...(alias ? { alias } : {}),
};
} else {

View File

@@ -1,12 +1,7 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js";
import type {
ElevatedLevel,
ReasoningLevel,
ThinkLevel,
VerboseLevel,
} from "./directives.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
import {
extractElevatedDirective,
extractReasoningDirective,
@@ -89,10 +84,9 @@ export function parseInlineDirectives(
}
: extractElevatedDirective(reasoningCleaned);
const allowStatusDirective = options?.allowStatusDirective !== false;
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } =
allowStatusDirective
? extractStatusDirective(elevatedCleaned)
: { cleaned: elevatedCleaned, hasDirective: false };
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } = allowStatusDirective
? extractStatusDirective(elevatedCleaned)
: { cleaned: elevatedCleaned, hasDirective: false };
const {
cleaned: modelCleaned,
rawModel,
@@ -167,8 +161,6 @@ export function isDirectiveOnly(params: {
)
return false;
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
const noMentions = isGroup
? stripMentions(stripped, ctx, cfg, agentId)
: stripped;
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg, agentId) : stripped;
return noMentions.length === 0;
}

View File

@@ -5,11 +5,7 @@ import {
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { lookupContextTokens } from "../../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../../agents/defaults.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import {
buildModelAliasIndex,
type ModelAliasIndex,
@@ -23,10 +19,7 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { resolveProfileOverride } from "./directive-handling.auth.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import {
formatElevatedEvent,
formatReasoningEvent,
} from "./directive-handling.shared.js";
import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js";
import type { ElevatedLevel, ReasoningLevel } from "./directives.js";
export async function persistInlineDirectives(params: {
@@ -78,16 +71,14 @@ export async function persistInlineDirectives(params: {
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(agentCfg?.elevatedDefault as ElevatedLevel | undefined) ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
const prevReasoningLevel =
(sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
const prevReasoningLevel = (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
let elevatedChanged =
directives.hasElevatedDirective &&
directives.elevatedLevel !== undefined &&
elevatedEnabled &&
elevatedAllowed;
let reasoningChanged =
directives.hasReasoningDirective &&
directives.reasoningLevel !== undefined;
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
let updated = false;
if (directives.hasThinkDirective && directives.thinkLevel) {
@@ -124,8 +115,7 @@ export async function persistInlineDirectives(params: {
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel &&
directives.elevatedLevel !== undefined);
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
updated = true;
}
@@ -156,8 +146,7 @@ export async function persistInlineDirectives(params: {
profileOverride = profileResolved.profileId;
}
const isDefault =
resolved.ref.provider === defaultProvider &&
resolved.ref.model === defaultModel;
resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel;
if (isDefault) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
@@ -174,13 +163,10 @@ export async function persistInlineDirectives(params: {
model = resolved.ref.model;
const nextLabel = `${provider}/${model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, resolved.alias),
{
sessionKey,
contextKey: `model:${nextLabel}`,
},
);
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, resolved.alias), {
sessionKey,
contextKey: `model:${nextLabel}`,
});
}
updated = true;
}
@@ -201,16 +187,14 @@ export async function persistInlineDirectives(params: {
await saveSessionStore(storePath, sessionStore);
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ??
"off") as ElevatedLevel;
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ??
"off") as ReasoningLevel;
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
@@ -222,17 +206,11 @@ export async function persistInlineDirectives(params: {
return {
provider,
model,
contextTokens:
agentCfg?.contextTokens ??
lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS,
contextTokens: agentCfg?.contextTokens ?? lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS,
};
}
export function resolveDefaultModel(params: {
cfg: ClawdbotConfig;
agentId?: string;
}): {
export function resolveDefaultModel(params: { cfg: ClawdbotConfig; agentId?: string }): {
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;

View File

@@ -29,11 +29,8 @@ export function maybeHandleQueueDirective(params: {
sessionEntry: params.sessionEntry,
});
const debounceLabel =
typeof settings.debounceMs === "number"
? `${settings.debounceMs}ms`
: "default";
const capLabel =
typeof settings.cap === "number" ? String(settings.cap) : "default";
typeof settings.debounceMs === "number" ? `${settings.debounceMs}ms` : "default";
const capLabel = typeof settings.cap === "number" ? String(settings.cap) : "default";
const dropLabel = settings.dropPolicy ?? "default";
return {
text: withOptions(
@@ -44,23 +41,13 @@ export function maybeHandleQueueDirective(params: {
}
const queueModeInvalid =
!directives.queueMode &&
!directives.queueReset &&
Boolean(directives.rawQueueMode);
!directives.queueMode && !directives.queueReset && Boolean(directives.rawQueueMode);
const queueDebounceInvalid =
directives.rawDebounce !== undefined &&
typeof directives.debounceMs !== "number";
const queueCapInvalid =
directives.rawCap !== undefined && typeof directives.cap !== "number";
const queueDropInvalid =
directives.rawDrop !== undefined && !directives.dropPolicy;
directives.rawDebounce !== undefined && typeof directives.debounceMs !== "number";
const queueCapInvalid = directives.rawCap !== undefined && typeof directives.cap !== "number";
const queueDropInvalid = directives.rawDrop !== undefined && !directives.dropPolicy;
if (
queueModeInvalid ||
queueDebounceInvalid ||
queueCapInvalid ||
queueDropInvalid
) {
if (queueModeInvalid || queueDebounceInvalid || queueCapInvalid || queueDropInvalid) {
const errors: string[] = [];
if (queueModeInvalid) {
errors.push(

View File

@@ -37,9 +37,7 @@ export function formatElevatedUnavailableText(params: {
);
const failures = params.failures ?? [];
if (failures.length > 0) {
lines.push(
`Failing gates: ${failures.map((f) => `${f.gate} (${f.key})`).join(", ")}`,
);
lines.push(`Failing gates: ${failures.map((f) => `${f.gate} (${f.key})`).join(", ")}`);
} else {
lines.push(
"Fix-it keys: tools.elevated.enabled, tools.elevated.allowFrom.<provider>, agents.list[].tools.elevated.*",

View File

@@ -1,12 +1,6 @@
export { applyInlineDirectivesFastLane } from "./directive-handling.fast-lane.js";
export * from "./directive-handling.impl.js";
export type { InlineDirectives } from "./directive-handling.parse.js";
export {
isDirectiveOnly,
parseInlineDirectives,
} from "./directive-handling.parse.js";
export {
persistInlineDirectives,
resolveDefaultModel,
} from "./directive-handling.persist.js";
export { isDirectiveOnly, parseInlineDirectives } from "./directive-handling.parse.js";
export { persistInlineDirectives, resolveDefaultModel } from "./directive-handling.persist.js";
export { formatDirectiveAck } from "./directive-handling.shared.js";

View File

@@ -16,17 +16,14 @@ type ExtractedLevel<T> = {
hasDirective: boolean;
};
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matchLevelDirective = (
body: string,
names: string[],
): { start: number; end: number; rawLevel?: string } | null => {
const namePattern = names.map(escapeRegExp).join("|");
const match = body.match(
new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)`, "i"),
);
const match = body.match(new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)`, "i"));
if (!match || match.index === undefined) return null;
const start = match.index;
let end = match.index + match[0].length;
@@ -76,9 +73,7 @@ const extractSimpleDirective = (
const match = body.match(
new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)(?:\\s*:\\s*)?`, "i"),
);
const cleaned = match
? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
: body.trim();
const cleaned = match ? body.replace(match[0], " ").replace(/\s+/g, " ").trim() : body.trim();
return {
cleaned,
hasDirective: Boolean(match),
@@ -92,11 +87,7 @@ export function extractThinkDirective(body?: string): {
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const extracted = extractLevelDirective(
body,
["thinking", "think", "t"],
normalizeThinkLevel,
);
const extracted = extractLevelDirective(body, ["thinking", "think", "t"], normalizeThinkLevel);
return {
cleaned: extracted.cleaned,
thinkLevel: extracted.level,
@@ -112,11 +103,7 @@ export function extractVerboseDirective(body?: string): {
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const extracted = extractLevelDirective(
body,
["verbose", "v"],
normalizeVerboseLevel,
);
const extracted = extractLevelDirective(body, ["verbose", "v"], normalizeVerboseLevel);
return {
cleaned: extracted.cleaned,
verboseLevel: extracted.level,
@@ -132,11 +119,7 @@ export function extractElevatedDirective(body?: string): {
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const extracted = extractLevelDirective(
body,
["elevated", "elev"],
normalizeElevatedLevel,
);
const extracted = extractLevelDirective(body, ["elevated", "elev"], normalizeElevatedLevel);
return {
cleaned: extracted.cleaned,
elevatedLevel: extracted.level,
@@ -152,11 +135,7 @@ export function extractReasoningDirective(body?: string): {
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const extracted = extractLevelDirective(
body,
["reasoning", "reason"],
normalizeReasoningLevel,
);
const extracted = extractLevelDirective(body, ["reasoning", "reason"], normalizeReasoningLevel);
return {
cleaned: extracted.cleaned,
reasoningLevel: extracted.level,

View File

@@ -17,14 +17,7 @@ vi.mock("./route-reply.js", () => ({
isRoutableChannel: (channel: string | undefined) =>
Boolean(
channel &&
[
"telegram",
"slack",
"discord",
"signal",
"imessage",
"whatsapp",
].includes(channel),
["telegram", "slack", "discord", "signal", "imessage", "whatsapp"].includes(channel),
),
routeReply: mocks.routeReply,
}));

View File

@@ -37,9 +37,7 @@ export async function dispatchReplyFromConfig(params: {
const originatingTo = ctx.OriginatingTo;
const currentSurface = (ctx.Surface ?? ctx.Provider)?.toLowerCase();
const shouldRouteToOriginating =
isRoutableChannel(originatingChannel) &&
originatingTo &&
originatingChannel !== currentSurface;
isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface;
/**
* Helper to send a payload via route-reply (async).
@@ -66,9 +64,7 @@ export async function dispatchReplyFromConfig(params: {
abortSignal,
});
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`,
);
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
}
};
@@ -129,11 +125,7 @@ export async function dispatchReplyFromConfig(params: {
cfg,
);
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
: [replyResult]
: [];
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
let queuedFinal = false;
let routedFinalCount = 0;

View File

@@ -48,10 +48,7 @@ describe("createFollowupRunner compaction", () => {
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: {
onAgentEvent?: (evt: {
stream: string;
data: Record<string, unknown>;
}) => void;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
}) => {
params.onAgentEvent?.({
stream: "compaction",
@@ -102,9 +99,7 @@ describe("createFollowupRunner compaction", () => {
await runner(queued);
expect(onBlockReply).toHaveBeenCalled();
expect(onBlockReply.mock.calls[0][0].text).toContain(
"Auto-compaction complete",
);
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
expect(sessionStore.main.compactionCount).toBe(1);
});
});

View File

@@ -103,9 +103,7 @@ describe("createFollowupRunner messaging tool dedupe", () => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [
{ tool: "slack", provider: "slack", to: "channel:C1" },
],
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
meta: {},
});

View File

@@ -66,14 +66,10 @@ export function createFollowupRunner(params: {
* session's current dispatcher. This ensures replies go back to
* where the message originated.
*/
const sendFollowupPayloads = async (
payloads: ReplyPayload[],
queued: FollowupRun,
) => {
const sendFollowupPayloads = async (payloads: ReplyPayload[], queued: FollowupRun) => {
// Check if we should route to originating channel.
const { originatingChannel, originatingTo } = queued;
const shouldRouteToOriginating =
isRoutableChannel(originatingChannel) && originatingTo;
const shouldRouteToOriginating = isRoutableChannel(originatingChannel) && originatingTo;
if (!shouldRouteToOriginating && !opts?.onBlockReply) {
logVerbose("followup queue: no onBlockReply handler; dropping payloads");
@@ -168,8 +164,7 @@ export function createFollowupRunner(params: {
blockReplyBreak: queued.run.blockReplyBreak,
onAgentEvent: (evt) => {
if (evt.stream !== "compaction") return;
const phase =
typeof evt.data.phase === "string" ? evt.data.phase : "";
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
autoCompactionCompleted = true;
@@ -182,9 +177,7 @@ export function createFollowupRunner(params: {
fallbackModel = fallbackResult.model;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.error?.(
`Followup agent failed before reply: ${message}`,
);
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
return;
}
@@ -194,16 +187,13 @@ export function createFollowupRunner(params: {
const text = payload.text;
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
const stripped = stripHeartbeatToken(text, { mode: "message" });
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return [];
return [{ ...payload, text: stripped.text }];
});
const replyToChannel =
queued.originatingChannel ??
(queued.run.messageProvider?.toLowerCase() as
| OriginatingChannelType
| undefined);
(queued.run.messageProvider?.toLowerCase() as OriginatingChannelType | undefined);
const replyToMode = resolveReplyToMode(
queued.run.config,
replyToChannel,
@@ -247,8 +237,7 @@ export function createFollowupRunner(params: {
if (storePath && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed =
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
@@ -263,13 +252,11 @@ export function createFollowupRunner(params: {
update: async (entry) => {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens =
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
return {
inputTokens: input,
outputTokens: output,
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
totalTokens: promptTokens > 0 ? promptTokens : (usage.total ?? input),
modelProvider: fallbackProvider ?? entry.modelProvider,
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
@@ -278,9 +265,7 @@ export function createFollowupRunner(params: {
},
});
} catch (err) {
logVerbose(
`failed to persist followup usage update: ${String(err)}`,
);
logVerbose(`failed to persist followup usage update: ${String(err)}`);
}
} else if (modelUsed || contextTokensUsed) {
try {
@@ -295,9 +280,7 @@ export function createFollowupRunner(params: {
}),
});
} catch (err) {
logVerbose(
`failed to persist followup model/context update: ${String(err)}`,
);
logVerbose(`failed to persist followup model/context update: ${String(err)}`);
}
}
}

Some files were not shown because too many files have changed in this diff Show More