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

@@ -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)}`);
}
}
}

View File

@@ -1,12 +1,7 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } 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 { buildStatusReply } from "./commands.js";
import {
@@ -176,11 +171,7 @@ export async function applyInlineDirectiveOverrides(params: {
currentElevatedLevel,
});
let statusReply: ReplyPayload | undefined;
if (
directives.hasStatusDirective &&
allowTextCommands &&
command.isAuthorizedSender
) {
if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) {
statusReply = await buildStatusReply({
cfg,
command,
@@ -192,8 +183,7 @@ export async function applyInlineDirectiveOverrides(params: {
contextTokens,
resolvedThinkLevel: resolvedDefaultThinkLevel,
resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel,
resolvedReasoningLevel: (currentReasoningLevel ??
"off") as ReasoningLevel,
resolvedReasoningLevel: (currentReasoningLevel ?? "off") as ReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel: async () => resolvedDefaultThinkLevel,
isGroup,
@@ -284,9 +274,7 @@ export async function applyInlineDirectiveOverrides(params: {
contextTokens = persisted.contextTokens;
const perMessageQueueMode =
directives.hasQueueDirective && !directives.queueReset
? directives.queueMode
: undefined;
directives.hasQueueDirective && !directives.queueReset ? directives.queueMode : undefined;
const perMessageQueueOptions =
directives.hasQueueDirective && !directives.queueReset
? {

View File

@@ -2,43 +2,19 @@ import type { ModelAliasIndex } from "../../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import {
listChatCommands,
shouldHandleTextCommands,
} from "../commands-registry.js";
import { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import type {
ElevatedLevel,
ReasoningLevel,
ThinkLevel,
VerboseLevel,
} from "../thinking.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { resolveBlockStreamingChunking } from "./block-streaming.js";
import { buildCommandContext } from "./commands.js";
import {
type InlineDirectives,
parseInlineDirectives,
} from "./directive-handling.js";
import { type InlineDirectives, parseInlineDirectives } from "./directive-handling.js";
import { applyInlineDirectiveOverrides } from "./get-reply-directives-apply.js";
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
import {
defaultGroupActivation,
resolveGroupRequireMention,
} from "./groups.js";
import {
CURRENT_MESSAGE_MARKER,
stripMentions,
stripStructuralPrefixes,
} from "./mentions.js";
import {
createModelSelectionState,
resolveContextTokens,
} from "./model-selection.js";
import {
formatElevatedUnavailableMessage,
resolveElevatedPermissions,
} from "./reply-elevated.js";
import { defaultGroupActivation, resolveGroupRequireMention } from "./groups.js";
import { CURRENT_MESSAGE_MARKER, stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { createModelSelectionState, resolveContextTokens } from "./model-selection.js";
import { formatElevatedUnavailableMessage, resolveElevatedPermissions } from "./reply-elevated.js";
import { stripInlineStatus } from "./reply-inline.js";
import type { TypingController } from "./typing.js";
@@ -95,12 +71,8 @@ export async function resolveReplyDirectives(params: {
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
sessionScope: Parameters<
typeof applyInlineDirectiveOverrides
>[0]["sessionScope"];
groupResolution: Parameters<
typeof resolveGroupRequireMention
>[0]["groupResolution"];
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
groupResolution: Parameters<typeof resolveGroupRequireMention>[0]["groupResolution"];
isGroup: boolean;
triggerBodyNormalized: string;
commandAuthorized: boolean;
@@ -175,19 +147,14 @@ export async function resolveReplyDirectives(params: {
allowStatusDirective,
});
const hasInlineStatus =
parsedDirectives.hasStatusDirective &&
parsedDirectives.cleaned.trim().length > 0;
parsedDirectives.hasStatusDirective && parsedDirectives.cleaned.trim().length > 0;
if (hasInlineStatus) {
parsedDirectives = {
...parsedDirectives,
hasStatusDirective: false,
};
}
if (
isGroup &&
ctx.WasMentioned !== true &&
parsedDirectives.hasElevatedDirective
) {
if (isGroup && ctx.WasMentioned !== true && parsedDirectives.hasElevatedDirective) {
if (parsedDirectives.elevatedLevel !== "off") {
parsedDirectives = {
...parsedDirectives,
@@ -206,18 +173,14 @@ export async function resolveReplyDirectives(params: {
parsedDirectives.hasQueueDirective;
if (hasInlineDirective) {
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
const noMentions = isGroup
? stripMentions(stripped, ctx, cfg, agentId)
: stripped;
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg, agentId) : stripped;
if (noMentions.trim().length > 0) {
const directiveOnlyCheck = parseInlineDirectives(noMentions, {
modelAliases: configuredAliases,
});
if (directiveOnlyCheck.cleaned.trim().length > 0) {
const allowInlineStatus =
parsedDirectives.hasStatusDirective &&
allowTextCommands &&
command.isAuthorizedSender;
parsedDirectives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender;
parsedDirectives = allowInlineStatus
? {
...clearInlineDirectives(parsedDirectives.cleaned),
@@ -257,13 +220,8 @@ export async function resolveReplyDirectives(params: {
}).cleaned;
}
const head = existingBody.slice(
0,
markerIndex + CURRENT_MESSAGE_MARKER.length,
);
const tail = existingBody.slice(
markerIndex + CURRENT_MESSAGE_MARKER.length,
);
const head = existingBody.slice(0, markerIndex + CURRENT_MESSAGE_MARKER.length);
const tail = existingBody.slice(markerIndex + CURRENT_MESSAGE_MARKER.length);
const cleanedTail = parseInlineDirectives(tail, {
modelAliases: configuredAliases,
allowStatusDirective,
@@ -279,9 +237,7 @@ export async function resolveReplyDirectives(params: {
sessionCtx.BodyStripped = cleanedBody;
const messageProviderKey =
sessionCtx.Provider?.trim().toLowerCase() ??
ctx.Provider?.trim().toLowerCase() ??
"";
sessionCtx.Provider?.trim().toLowerCase() ?? ctx.Provider?.trim().toLowerCase() ?? "";
const elevated = resolveElevatedPermissions({
cfg,
agentId,
@@ -291,10 +247,7 @@ export async function resolveReplyDirectives(params: {
const elevatedEnabled = elevated.enabled;
const elevatedAllowed = elevated.allowed;
const elevatedFailures = elevated.failures;
if (
directives.hasElevatedDirective &&
(!elevatedEnabled || !elevatedAllowed)
) {
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
typing.cleanup();
const runtimeSandboxed = resolveSandboxRuntimeStatus({
cfg,
@@ -346,17 +299,11 @@ export async function resolveReplyDirectives(params: {
? "on"
: "off";
const resolvedBlockStreamingBreak: "text_end" | "message_end" =
agentCfg?.blockStreamingBreak === "message_end"
? "message_end"
: "text_end";
agentCfg?.blockStreamingBreak === "message_end" ? "message_end" : "text_end";
const blockStreamingEnabled =
resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true;
const blockReplyChunking = blockStreamingEnabled
? resolveBlockStreamingChunking(
cfg,
sessionCtx.Provider,
sessionCtx.AccountId,
)
? resolveBlockStreamingChunking(cfg, sessionCtx.Provider, sessionCtx.AccountId)
: undefined;
const modelState = await createModelSelectionState({
@@ -382,20 +329,13 @@ export async function resolveReplyDirectives(params: {
const initialModelLabel = `${provider}/${model}`;
const formatModelSwitchEvent = (label: string, alias?: string) =>
alias
? `Model switched to ${alias} (${label}).`
: `Model switched to ${label}.`;
alias ? `Model switched to ${alias} (${label}).` : `Model switched to ${label}.`;
const isModelListAlias =
directives.hasModelDirective &&
["status", "list"].includes(
directives.rawModelDirective?.trim().toLowerCase() ?? "",
);
const effectiveModelDirective = isModelListAlias
? undefined
: directives.rawModelDirective;
["status", "list"].includes(directives.rawModelDirective?.trim().toLowerCase() ?? "");
const effectiveModelDirective = isModelListAlias ? undefined : directives.rawModelDirective;
const inlineStatusRequested =
hasInlineStatus && allowTextCommands && command.isAuthorizedSender;
const inlineStatusRequested = hasInlineStatus && allowTextCommands && command.isAuthorizedSender;
const applyResult = await applyInlineDirectiveOverrides({
ctx,
@@ -437,8 +377,7 @@ export async function resolveReplyDirectives(params: {
provider = applyResult.provider;
model = applyResult.model;
contextTokens = applyResult.contextTokens;
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } =
applyResult;
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } = applyResult;
return {
kind: "continue",

View File

@@ -2,12 +2,7 @@ import { getChannelDock } from "../../channels/dock.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import type {
ElevatedLevel,
ReasoningLevel,
ThinkLevel,
VerboseLevel,
} from "../thinking.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { getAbortMemory } from "./abort.js";
import { buildStatusReply, handleCommands } from "./commands.js";
@@ -47,9 +42,7 @@ export async function handleInlineActions(params: {
elevatedEnabled: boolean;
elevatedAllowed: boolean;
elevatedFailures: Array<{ gate: string; key: string }>;
defaultActivation: Parameters<
typeof buildStatusReply
>[0]["defaultGroupActivation"];
defaultActivation: Parameters<typeof buildStatusReply>[0]["defaultGroupActivation"];
resolvedThinkLevel: ThinkLevel | undefined;
resolvedVerboseLevel: VerboseLevel | undefined;
resolvedReasoningLevel: ReasoningLevel;

View File

@@ -163,8 +163,7 @@ export async function runPreparedReply(
isHeartbeat,
});
const shouldInjectGroupIntro = Boolean(
isGroupChat &&
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
);
const groupIntro = shouldInjectGroupIntro
? buildGroupIntro({
@@ -176,17 +175,10 @@ export async function runPreparedReply(
})
: "";
const groupSystemPrompt = sessionCtx.GroupSystemPrompt?.trim() ?? "";
const extraSystemPrompt = [groupIntro, groupSystemPrompt]
.filter(Boolean)
.join("\n\n");
const extraSystemPrompt = [groupIntro, groupSystemPrompt].filter(Boolean).join("\n\n");
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
// Use CommandBody/RawBody for bare reset detection (clean message without structural context).
const rawBodyTrimmed = (
ctx.CommandBody ??
ctx.RawBody ??
ctx.Body ??
""
).trim();
const rawBodyTrimmed = (ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "").trim();
const baseBodyTrimmedRaw = baseBody.trim();
if (
allowTextCommands &&
@@ -198,12 +190,8 @@ export async function runPreparedReply(
return undefined;
}
const isBareSessionReset =
isNewSession &&
baseBodyTrimmedRaw.length === 0 &&
rawBodyTrimmed.length > 0;
const baseBodyFinal = isBareSessionReset
? BARE_SESSION_RESET_PROMPT
: baseBody;
isNewSession && baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0;
const baseBodyFinal = isBareSessionReset ? BARE_SESSION_RESET_PROMPT : baseBody;
const baseBodyTrimmed = baseBodyFinal.trim();
if (!baseBodyTrimmed) {
await typing.onReplyStart();
@@ -223,10 +211,8 @@ export async function runPreparedReply(
abortKey: command.abortKey,
messageId: sessionCtx.MessageSid,
});
const isGroupSession =
sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
const isMainSession =
!isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey);
const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
const isMainSession = !isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey);
prefixedBodyBase = await prependSystemEvents({
cfg,
sessionKey,
@@ -263,18 +249,12 @@ export async function runPreparedReply(
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
: undefined;
let prefixedCommandBody = mediaNote
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
.filter(Boolean)
.join("\n")
.trim()
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()
: prefixedBody;
if (!resolvedThinkLevel && prefixedCommandBody) {
const parts = prefixedCommandBody.split(/\s+/);
const maybeLevel = normalizeThinkLevel(parts[0]);
if (
maybeLevel &&
(maybeLevel !== "xhigh" || supportsXHighThinking(provider, model))
) {
if (maybeLevel && (maybeLevel !== "xhigh" || supportsXHighThinking(provider, model))) {
resolvedThinkLevel = maybeLevel;
prefixedCommandBody = parts.slice(1).join(" ").trim();
}
@@ -282,12 +262,8 @@ export async function runPreparedReply(
if (!resolvedThinkLevel) {
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
}
if (
resolvedThinkLevel === "xhigh" &&
!supportsXHighThinking(provider, model)
) {
const explicitThink =
directives.hasThinkDirective && directives.thinkLevel !== undefined;
if (resolvedThinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
const explicitThink = directives.hasThinkDirective && directives.thinkLevel !== undefined;
if (explicitThink) {
typing.cleanup();
return {
@@ -295,12 +271,7 @@ export async function runPreparedReply(
};
}
resolvedThinkLevel = "high";
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === "xhigh"
) {
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") {
sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
@@ -317,10 +288,7 @@ export async function runPreparedReply(
.join("\n\n")
: [threadStarterNote, baseBodyFinal].filter(Boolean).join("\n\n");
const queuedBody = mediaNote
? [mediaNote, mediaReplyHint, queueBodyBase]
.filter(Boolean)
.join("\n")
.trim()
? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim()
: queueBodyBase;
const resolvedQueue = resolveQueueSettings({
cfg,
@@ -329,22 +297,17 @@ export async function runPreparedReply(
inlineMode: perMessageQueueMode,
inlineOptions: perMessageQueueOptions,
});
const sessionLaneKey = resolveEmbeddedSessionLane(
sessionKey ?? sessionIdFinal,
);
const sessionLaneKey = resolveEmbeddedSessionLane(sessionKey ?? sessionIdFinal);
const laneSize = getQueueSize(sessionLaneKey);
if (resolvedQueue.mode === "interrupt" && laneSize > 0) {
const cleared = clearCommandLane(sessionLaneKey);
const aborted = abortEmbeddedPiRun(sessionIdFinal);
logVerbose(
`Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`,
);
logVerbose(`Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`);
}
const queueKey = sessionKey ?? sessionIdFinal;
const isActive = isEmbeddedPiRunActive(sessionIdFinal);
const isStreaming = isEmbeddedPiRunStreaming(sessionIdFinal);
const shouldSteer =
resolvedQueue.mode === "steer" || resolvedQueue.mode === "steer-backlog";
const shouldSteer = resolvedQueue.mode === "steer" || resolvedQueue.mode === "steer-backlog";
const shouldFollowup =
resolvedQueue.mode === "followup" ||
resolvedQueue.mode === "collect" ||
@@ -385,8 +348,7 @@ export async function runPreparedReply(
},
timeoutMs,
blockReplyBreak: resolvedBlockStreamingBreak,
ownerNumbers:
command.ownerList.length > 0 ? command.ownerList : undefined,
ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined,
extraSystemPrompt: extraSystemPrompt || undefined,
...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}),
},

View File

@@ -5,21 +5,14 @@ import {
} from "../../agents/agent-scope.js";
import { resolveModelRefFromString } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
} from "../../agents/workspace.js";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js";
import { type ClawdbotConfig, loadConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import {
hasAudioTranscriptionConfig,
isAudio,
transcribeInboundAudio,
} from "../transcription.js";
import { hasAudioTranscriptionConfig, isAudio, transcribeInboundAudio } from "../transcription.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { resolveDefaultModel } from "./directive-handling.js";
import { resolveReplyDirectives } from "./get-reply-directives.js";
@@ -62,8 +55,7 @@ export async function getReplyFromConfig(
}
}
const workspaceDirRaw =
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !agentCfg?.skipBootstrap,

View File

@@ -32,9 +32,7 @@ describe("resolveGroupRequireMention", () => {
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
false,
);
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("respects Slack channel requireMention settings", () => {
@@ -58,8 +56,6 @@ describe("resolveGroupRequireMention", () => {
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
false,
);
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
});

View File

@@ -1,13 +1,7 @@
import { getChannelDock } from "../../channels/dock.js";
import {
getChatChannelMeta,
normalizeChannelId,
} from "../../channels/registry.js";
import { getChatChannelMeta, normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type {
GroupKeyResolution,
SessionEntry,
} from "../../config/sessions.js";
import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { normalizeGroupActivation } from "../group-activation.js";
import type { TemplateContext } from "../templating.js";
@@ -24,9 +18,7 @@ export function resolveGroupRequireMention(params: {
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
const requireMention = getChannelDock(
channel,
)?.groups?.resolveRequireMention?.({
const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
cfg,
groupId,
groupRoom,
@@ -37,9 +29,7 @@ export function resolveGroupRequireMention(params: {
return true;
}
export function defaultGroupActivation(
requireMention: boolean,
): "always" | "mention" {
export function defaultGroupActivation(requireMention: boolean): "always" | "mention" {
return requireMention === false ? "always" : "mention";
}
@@ -51,8 +41,7 @@ export function buildGroupIntro(params: {
silentToken: string;
}): string {
const activation =
normalizeGroupActivation(params.sessionEntry?.groupActivation) ??
params.defaultActivation;
normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation;
const subject = params.sessionCtx.GroupSubject?.trim();
const members = params.sessionCtx.GroupMembers?.trim();
const rawProvider = params.sessionCtx.Provider?.trim();

View File

@@ -56,10 +56,7 @@ describe("history helpers", () => {
entry: { sender: "C", body: "three" },
});
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual([
"two",
"three",
]);
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual(["two", "three"]);
});
it("builds context from map and appends entry", () => {
@@ -78,11 +75,7 @@ describe("history helpers", () => {
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
});
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual([
"one",
"two",
"three",
]);
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]);
expect(result).toContain(HISTORY_CONTEXT_MARKER);
expect(result).toContain("A: one");
expect(result).toContain("B: two");

View File

@@ -1,7 +1,6 @@
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
export const HISTORY_CONTEXT_MARKER =
"[Chat messages since your last reply - for context]";
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
export type HistoryEntry = {
@@ -19,13 +18,9 @@ export function buildHistoryContext(params: {
const { historyText, currentMessage } = params;
const lineBreak = params.lineBreak ?? "\n";
if (!historyText.trim()) return currentMessage;
return [
HISTORY_CONTEXT_MARKER,
historyText,
"",
CURRENT_MESSAGE_MARKER,
currentMessage,
].join(lineBreak);
return [HISTORY_CONTEXT_MARKER, historyText, "", CURRENT_MESSAGE_MARKER, currentMessage].join(
lineBreak,
);
}
export function appendHistoryEntry(params: {
@@ -86,8 +81,7 @@ export function buildHistoryContextFromEntries(params: {
excludeLast?: boolean;
}): string {
const lineBreak = params.lineBreak ?? "\n";
const entries =
params.excludeLast === false ? params.entries : params.entries.slice(0, -1);
const entries = params.excludeLast === false ? params.entries : params.entries.slice(0, -1);
if (entries.length === 0) return params.currentMessage;
const historyText = entries.map(params.formatEntry).join(lineBreak);
return buildHistoryContext({

View File

@@ -38,16 +38,10 @@ describe("inbound dedupe", () => {
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound(
{ ...base, OriginatingTo: "whatsapp:+1000" },
{ now: 100 },
),
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound(
{ ...base, OriginatingTo: "whatsapp:+2000" },
{ now: 200 },
),
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
).toBe(false);
});
@@ -60,22 +54,13 @@ describe("inbound dedupe", () => {
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound(
{ ...base, SessionKey: "agent:alpha:main" },
{ now: 100 },
),
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound(
{ ...base, SessionKey: "agent:bravo:main" },
{ now: 200 },
),
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound(
{ ...base, SessionKey: "agent:alpha:main" },
{ now: 300 },
),
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
).toBe(true);
});
});

View File

@@ -10,27 +10,21 @@ const inboundDedupeCache = createDedupeCache({
maxSize: DEFAULT_INBOUND_DEDUPE_MAX,
});
const normalizeProvider = (value?: string | null) =>
value?.trim().toLowerCase() || "";
const normalizeProvider = (value?: string | null) => value?.trim().toLowerCase() || "";
const resolveInboundPeerId = (ctx: MsgContext) =>
ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? ctx.SessionKey;
export function buildInboundDedupeKey(ctx: MsgContext): string | null {
const provider = normalizeProvider(
ctx.OriginatingChannel ?? ctx.Provider ?? ctx.Surface,
);
const provider = normalizeProvider(ctx.OriginatingChannel ?? ctx.Provider ?? ctx.Surface);
const messageId = ctx.MessageSid?.trim();
if (!provider || !messageId) return null;
const peerId = resolveInboundPeerId(ctx);
if (!peerId) return null;
const sessionKey = ctx.SessionKey?.trim() ?? "";
const accountId = ctx.AccountId?.trim() ?? "";
const threadId =
typeof ctx.MessageThreadId === "number" ? String(ctx.MessageThreadId) : "";
return [provider, accountId, sessionKey, peerId, threadId, messageId]
.filter(Boolean)
.join("|");
const threadId = typeof ctx.MessageThreadId === "number" ? String(ctx.MessageThreadId) : "";
return [provider, accountId, sessionKey, peerId, threadId, messageId].filter(Boolean).join("|");
}
export function shouldSkipDuplicateInbound(

View File

@@ -118,8 +118,6 @@ describe("shouldRunMemoryFlush", () => {
describe("resolveMemoryFlushContextWindowTokens", () => {
it("falls back to agent config or default tokens", () => {
expect(
resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 }),
).toBe(42_000);
expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000);
});
});

View File

@@ -33,22 +33,17 @@ const normalizeNonNegativeInt = (value: unknown): number | null => {
return int >= 0 ? int : null;
};
export function resolveMemoryFlushSettings(
cfg?: ClawdbotConfig,
): MemoryFlushSettings | null {
export function resolveMemoryFlushSettings(cfg?: ClawdbotConfig): MemoryFlushSettings | null {
const defaults = cfg?.agents?.defaults?.compaction?.memoryFlush;
const enabled = defaults?.enabled ?? true;
if (!enabled) return null;
const softThresholdTokens =
normalizeNonNegativeInt(defaults?.softThresholdTokens) ??
DEFAULT_MEMORY_FLUSH_SOFT_TOKENS;
normalizeNonNegativeInt(defaults?.softThresholdTokens) ?? DEFAULT_MEMORY_FLUSH_SOFT_TOKENS;
const prompt = defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT;
const systemPrompt =
defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT;
const systemPrompt = defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT;
const reserveTokensFloor =
normalizeNonNegativeInt(
cfg?.agents?.defaults?.compaction?.reserveTokensFloor,
) ?? DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
return {
enabled,
@@ -69,17 +64,12 @@ export function resolveMemoryFlushContextWindowTokens(params: {
agentCfgContextTokens?: number;
}): number {
return (
lookupContextTokens(params.modelId) ??
params.agentCfgContextTokens ??
DEFAULT_CONTEXT_TOKENS
lookupContextTokens(params.modelId) ?? params.agentCfgContextTokens ?? DEFAULT_CONTEXT_TOKENS
);
}
export function shouldRunMemoryFlush(params: {
entry?: Pick<
SessionEntry,
"totalTokens" | "compactionCount" | "memoryFlushCompactionCount"
>;
entry?: Pick<SessionEntry, "totalTokens" | "compactionCount" | "memoryFlushCompactionCount">;
contextWindowTokens: number;
reserveTokensFloor: number;
softThresholdTokens: number;

View File

@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildMentionRegexes,
matchesMentionPatterns,
normalizeMentionText,
} from "./mentions.js";
import { buildMentionRegexes, matchesMentionPatterns, normalizeMentionText } from "./mentions.js";
describe("mention helpers", () => {
it("builds regexes and skips invalid patterns", () => {

View File

@@ -36,10 +36,7 @@ function normalizeMentionPatterns(patterns: string[]): string[] {
return patterns.map(normalizeMentionPattern);
}
function resolveMentionPatterns(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): string[] {
function resolveMentionPatterns(cfg: ClawdbotConfig | undefined, agentId?: string): string[] {
if (!cfg) return [];
const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
const agentGroupChat = agentConfig?.groupChat;
@@ -54,13 +51,8 @@ function resolveMentionPatterns(
return derived.length > 0 ? derived : [];
}
export function buildMentionRegexes(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): RegExp[] {
const patterns = normalizeMentionPatterns(
resolveMentionPatterns(cfg, agentId),
);
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined, agentId?: string): RegExp[] {
const patterns = normalizeMentionPatterns(resolveMentionPatterns(cfg, agentId));
return patterns
.map((pattern) => {
try {
@@ -73,15 +65,10 @@ export function buildMentionRegexes(
}
export function normalizeMentionText(text: string): string {
return (text ?? "")
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
.toLowerCase();
return (text ?? "").replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "").toLowerCase();
}
export function matchesMentionPatterns(
text: string,
mentionRegexes: RegExp[],
): boolean {
export function matchesMentionPatterns(text: string, mentionRegexes: RegExp[]): boolean {
if (mentionRegexes.length === 0) return false;
const cleaned = normalizeMentionText(text ?? "");
if (!cleaned) return false;
@@ -92,11 +79,7 @@ export function stripStructuralPrefixes(text: string): string {
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
// detection still works in group batches that include history/context.
const afterMarker = text.includes(CURRENT_MESSAGE_MARKER)
? text
.slice(
text.indexOf(CURRENT_MESSAGE_MARKER) + CURRENT_MESSAGE_MARKER.length,
)
.trimStart()
? text.slice(text.indexOf(CURRENT_MESSAGE_MARKER) + CURRENT_MESSAGE_MARKER.length).trimStart()
: text;
return afterMarker
@@ -115,9 +98,7 @@ export function stripMentions(
): string {
let result = text;
const providerId = ctx.Provider ? normalizeChannelId(ctx.Provider) : null;
const providerMentions = providerId
? getChannelDock(providerId)?.mentions
: undefined;
const providerMentions = providerId ? getChannelDock(providerId)?.mentions : undefined;
const patterns = normalizeMentionPatterns([
...resolveMentionPatterns(cfg, agentId),
...(providerMentions?.stripPatterns?.({ ctx, cfg, agentId }) ?? []),

View File

@@ -105,15 +105,9 @@ function scoreFuzzyMatch(params: {
score += 30;
}
const fragmentVariants = FUZZY_VARIANT_TOKENS.filter((token) =>
fragment.includes(token),
);
const modelVariants = FUZZY_VARIANT_TOKENS.filter((token) =>
modelLower.includes(token),
);
const variantMatchCount = fragmentVariants.filter((token) =>
modelLower.includes(token),
).length;
const fragmentVariants = FUZZY_VARIANT_TOKENS.filter((token) => fragment.includes(token));
const modelVariants = FUZZY_VARIANT_TOKENS.filter((token) => modelLower.includes(token));
const variantMatchCount = fragmentVariants.filter((token) => modelLower.includes(token)).length;
const variantCount = modelVariants.length;
if (fragmentVariants.length === 0 && variantCount > 0) {
score -= variantCount * 30;
@@ -123,8 +117,7 @@ function scoreFuzzyMatch(params: {
}
const defaultProvider = normalizeProviderId(params.defaultProvider);
const isDefault =
provider === defaultProvider && model === params.defaultModel;
const isDefault = provider === defaultProvider && model === params.defaultModel;
if (isDefault) score += 20;
return {
@@ -139,9 +132,7 @@ function scoreFuzzyMatch(params: {
export async function createModelSelectionState(params: {
cfg: ClawdbotConfig;
agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
agentCfg: NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]> | undefined;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
@@ -166,13 +157,9 @@ export async function createModelSelectionState(params: {
let provider = params.provider;
let model = params.model;
const hasAllowlist =
agentCfg?.models && Object.keys(agentCfg.models).length > 0;
const hasStoredOverride = Boolean(
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
);
const needsModelCatalog =
params.hasModelDirective || hasAllowlist || hasStoredOverride;
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride);
const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride;
let allowedModelKeys = new Set<string>();
let allowedModelCatalog: ModelCatalog = [];
@@ -192,8 +179,7 @@ export async function createModelSelectionState(params: {
}
if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) {
const overrideProvider =
sessionEntry.providerOverride?.trim() || defaultProvider;
const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider;
const overrideModel = sessionEntry.modelOverride?.trim();
if (overrideModel) {
const key = modelKey(overrideProvider, overrideModel);
@@ -221,15 +207,8 @@ export async function createModelSelectionState(params: {
}
}
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.authProfileOverride
) {
const { ensureAuthProfileStore } = await import(
"../../agents/auth-profiles.js"
);
if (sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride) {
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js");
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
@@ -259,9 +238,7 @@ export async function createModelSelectionState(params: {
catalog: catalogForThinking,
});
defaultThinkingLevel =
resolved ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined) ??
"off";
resolved ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? "off";
return defaultThinkingLevel;
};
@@ -283,21 +260,15 @@ export function resolveModelDirectiveSelection(params: {
aliasIndex: ModelAliasIndex;
allowedModelKeys: Set<string>;
}): { selection?: ModelDirectiveSelection; error?: string } {
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } =
params;
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } = params;
const rawTrimmed = raw.trim();
const rawLower = rawTrimmed.toLowerCase();
const pickAliasForKey = (
provider: string,
model: string,
): string | undefined => aliasIndex.byKey.get(modelKey(provider, model))?.[0];
const pickAliasForKey = (provider: string, model: string): string | undefined =>
aliasIndex.byKey.get(modelKey(provider, model))?.[0];
const buildSelection = (
provider: string,
model: string,
): ModelDirectiveSelection => {
const buildSelection = (provider: string, model: string): ModelDirectiveSelection => {
const alias = pickAliasForKey(provider, model);
return {
provider,
@@ -320,13 +291,9 @@ export function resolveModelDirectiveSelection(params: {
if (slash <= 0) continue;
const provider = normalizeProviderId(key.slice(0, slash));
const model = key.slice(slash + 1);
if (params.provider && provider !== normalizeProviderId(params.provider))
continue;
if (params.provider && provider !== normalizeProviderId(params.provider)) continue;
const haystack = `${provider}/${model}`.toLowerCase();
if (
haystack.includes(fragment) ||
model.toLowerCase().includes(fragment)
) {
if (haystack.includes(fragment) || model.toLowerCase().includes(fragment)) {
candidates.push({ provider, model });
}
}
@@ -344,11 +311,7 @@ export function resolveModelDirectiveSelection(params: {
for (const match of aliasMatches) {
const key = modelKey(match.provider, match.model);
if (!allowedModelKeys.has(key)) continue;
if (
!candidates.some(
(c) => c.provider === match.provider && c.model === match.model,
)
) {
if (!candidates.some((c) => c.provider === match.provider && c.model === match.model)) {
candidates.push(match);
}
}
@@ -378,10 +341,8 @@ export function resolveModelDirectiveSelection(params: {
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
if (a.variantMatchCount !== b.variantMatchCount)
return b.variantMatchCount - a.variantMatchCount;
if (a.variantCount !== b.variantCount)
return a.variantCount - b.variantCount;
if (a.modelLength !== b.modelLength)
return a.modelLength - b.modelLength;
if (a.variantCount !== b.variantCount) return a.variantCount - b.variantCount;
if (a.modelLength !== b.modelLength) return a.modelLength - b.modelLength;
return a.key.localeCompare(b.key);
});
@@ -410,9 +371,7 @@ export function resolveModelDirectiveSelection(params: {
selection: {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault:
resolved.ref.provider === defaultProvider &&
resolved.ref.model === defaultModel,
isDefault: resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel,
alias: resolved.alias,
},
};
@@ -438,14 +397,10 @@ export function resolveModelDirectiveSelection(params: {
}
export function resolveContextTokens(params: {
agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
agentCfg: NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]> | undefined;
model: string;
}): number {
return (
params.agentCfg?.contextTokens ??
lookupContextTokens(params.model) ??
DEFAULT_CONTEXT_TOKENS
params.agentCfg?.contextTokens ?? lookupContextTokens(params.model) ?? DEFAULT_CONTEXT_TOKENS
);
}

View File

@@ -1,9 +1,5 @@
import { stripHeartbeatToken } from "../heartbeat.js";
import {
HEARTBEAT_TOKEN,
isSilentReplyText,
SILENT_REPLY_TOKEN,
} from "../tokens.js";
import { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
export type NormalizeReplyOptions = {
@@ -17,9 +13,7 @@ export function normalizeReplyPayload(
payload: ReplyPayload,
opts: NormalizeReplyOptions = {},
): ReplyPayload | null {
const hasMedia = Boolean(
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
);
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
const trimmed = payload.text?.trim() ?? "";
if (!trimmed && !hasMedia) return null;

View File

@@ -17,8 +17,9 @@ export async function dispatchReplyWithBufferedBlockDispatcher(params: {
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
}): Promise<DispatchFromConfigResult> {
const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping(params.dispatcherOptions);
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
const result = await dispatchReplyFromConfig({
ctx: params.ctx,

View File

@@ -91,9 +91,7 @@ describe("followup queue deduplication", () => {
scheduleFollowupDrain(key, runFollowup);
await expect.poll(() => calls.length).toBe(1);
// Should collect both unique messages
expect(calls[0]?.prompt).toContain(
"[Queued messages while agent was busy]",
);
expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]");
});
it("deduplicates exact prompt when routing matches and no message id", async () => {
@@ -282,9 +280,7 @@ describe("followup queue collect routing", () => {
scheduleFollowupDrain(key, runFollowup);
await expect.poll(() => calls.length).toBe(1);
expect(calls[0]?.prompt).toContain(
"[Queued messages while agent was busy]",
);
expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]");
expect(calls[0]?.originatingChannel).toBe("slack");
expect(calls[0]?.originatingTo).toBe("channel:A");
});

View File

@@ -3,10 +3,7 @@ import { isRoutableChannel } from "../route-reply.js";
import { FOLLOWUP_QUEUES } from "./state.js";
import type { FollowupRun } from "./types.js";
async function waitForQueueDebounce(queue: {
debounceMs: number;
lastEnqueuedAt: number;
}) {
async function waitForQueueDebounce(queue: { debounceMs: number; lastEnqueuedAt: number }) {
const debounceMs = Math.max(0, queue.debounceMs);
if (debounceMs <= 0) return;
while (true) {
@@ -71,12 +68,9 @@ function hasCrossChannelItems(items: FollowupRun[]): boolean {
return true;
}
keys.add(
[
channel,
to,
accountId || "",
typeof threadId === "number" ? String(threadId) : "",
].join("|"),
[channel, to, accountId || "", typeof threadId === "number" ? String(threadId) : ""].join(
"|",
),
);
}
@@ -127,12 +121,8 @@ export function scheduleFollowupDrain(
if (!run) break;
// Preserve originating channel from items when collecting same-channel.
const originatingChannel = items.find(
(i) => i.originatingChannel,
)?.originatingChannel;
const originatingTo = items.find(
(i) => i.originatingTo,
)?.originatingTo;
const originatingChannel = items.find((i) => i.originatingChannel)?.originatingChannel;
const originatingTo = items.find((i) => i.originatingTo)?.originatingTo;
const originatingAccountId = items.find(
(i) => i.originatingAccountId,
)?.originatingAccountId;
@@ -170,9 +160,7 @@ export function scheduleFollowupDrain(
await runFollowup(next);
}
} catch (err) {
defaultRuntime.error?.(
`followup queue drain failed for ${key}: ${String(err)}`,
);
defaultRuntime.error?.(`followup queue drain failed for ${key}: ${String(err)}`);
} finally {
queue.draining = false;
if (queue.items.length === 0 && queue.droppedCount === 0) {

View File

@@ -25,14 +25,10 @@ function isRunAlreadyQueued(
const messageId = run.messageId?.trim();
if (messageId) {
return items.some(
(item) => item.messageId?.trim() === messageId && hasSameRouting(item),
);
return items.some((item) => item.messageId?.trim() === messageId && hasSameRouting(item));
}
if (!allowPromptFallback) return false;
return items.some(
(item) => item.prompt === run.prompt && hasSameRouting(item),
);
return items.some((item) => item.prompt === run.prompt && hasSameRouting(item));
}
export function enqueueFollowupRun(

View File

@@ -4,32 +4,18 @@ export function normalizeQueueMode(raw?: string): QueueMode | undefined {
if (!raw) return undefined;
const cleaned = raw.trim().toLowerCase();
if (cleaned === "queue" || cleaned === "queued") return "steer";
if (
cleaned === "interrupt" ||
cleaned === "interrupts" ||
cleaned === "abort"
)
if (cleaned === "interrupt" || cleaned === "interrupts" || cleaned === "abort")
return "interrupt";
if (cleaned === "steer" || cleaned === "steering") return "steer";
if (
cleaned === "followup" ||
cleaned === "follow-ups" ||
cleaned === "followups"
)
if (cleaned === "followup" || cleaned === "follow-ups" || cleaned === "followups")
return "followup";
if (cleaned === "collect" || cleaned === "coalesce") return "collect";
if (
cleaned === "steer+backlog" ||
cleaned === "steer-backlog" ||
cleaned === "steer_backlog"
)
if (cleaned === "steer+backlog" || cleaned === "steer-backlog" || cleaned === "steer_backlog")
return "steer-backlog";
return undefined;
}
export function normalizeQueueDropPolicy(
raw?: string,
): QueueDropPolicy | undefined {
export function normalizeQueueDropPolicy(raw?: string): QueueDropPolicy | undefined {
if (!raw) return undefined;
const cleaned = raw.trim().toLowerCase();
if (cleaned === "old" || cleaned === "oldest") return "old";

View File

@@ -1,22 +1,12 @@
import { normalizeQueueDropPolicy, normalizeQueueMode } from "./normalize.js";
import {
DEFAULT_QUEUE_CAP,
DEFAULT_QUEUE_DEBOUNCE_MS,
DEFAULT_QUEUE_DROP,
} from "./state.js";
import type {
QueueMode,
QueueSettings,
ResolveQueueSettingsParams,
} from "./types.js";
import { DEFAULT_QUEUE_CAP, DEFAULT_QUEUE_DEBOUNCE_MS, DEFAULT_QUEUE_DROP } from "./state.js";
import type { QueueMode, QueueSettings, ResolveQueueSettingsParams } from "./types.js";
function defaultQueueModeForChannel(_channel?: string): QueueMode {
return "collect";
}
export function resolveQueueSettings(
params: ResolveQueueSettingsParams,
): QueueSettings {
export function resolveQueueSettings(params: ResolveQueueSettingsParams): QueueSettings {
const channelKey = params.channel?.trim().toLowerCase();
const queueCfg = params.cfg.messages?.queue;
const providerModeRaw =
@@ -46,10 +36,8 @@ export function resolveQueueSettings(
DEFAULT_QUEUE_DROP;
return {
mode: resolvedMode,
debounceMs:
typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
cap:
typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
debounceMs: typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
cap: typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
dropPolicy: dropRaw,
};
}

View File

@@ -1,9 +1,4 @@
import type {
FollowupRun,
QueueDropPolicy,
QueueMode,
QueueSettings,
} from "./types.js";
import type { FollowupRun, QueueDropPolicy, QueueMode, QueueSettings } from "./types.js";
export type FollowupQueueState = {
items: FollowupRun[];
@@ -24,10 +19,7 @@ export const DEFAULT_QUEUE_DROP: QueueDropPolicy = "summarize";
export const FOLLOWUP_QUEUES = new Map<string, FollowupQueueState>();
export function getFollowupQueue(
key: string,
settings: QueueSettings,
): FollowupQueueState {
export function getFollowupQueue(key: string, settings: QueueSettings): FollowupQueueState {
const existing = FOLLOWUP_QUEUES.get(key);
if (existing) {
existing.mode = settings.mode;

View File

@@ -2,20 +2,9 @@ import type { SkillSnapshot } from "../../../agents/skills.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import type { SessionEntry } from "../../../config/sessions.js";
import type { OriginatingChannelType } from "../../templating.js";
import type {
ElevatedLevel,
ReasoningLevel,
ThinkLevel,
VerboseLevel,
} from "../directives.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
export type QueueMode =
| "steer"
| "followup"
| "collect"
| "steer-backlog"
| "interrupt"
| "queue";
export type QueueMode = "steer" | "followup" | "collect" | "steer-backlog" | "interrupt" | "queue";
export type QueueDropPolicy = "old" | "new" | "summarize";

View File

@@ -10,9 +10,7 @@ describe("createReplyDispatcher", () => {
expect(dispatcher.sendFinalReply({})).toBe(false);
expect(dispatcher.sendFinalReply({ text: " " })).toBe(false);
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false);
expect(
dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` }),
).toBe(false);
expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false);
await dispatcher.waitForIdle();
expect(deliver).not.toHaveBeenCalled();
@@ -28,9 +26,7 @@ describe("createReplyDispatcher", () => {
});
expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false);
expect(
dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` }),
).toBe(true);
expect(dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` })).toBe(true);
await dispatcher.waitForIdle();
expect(deliver).toHaveBeenCalledTimes(1);
@@ -91,9 +87,7 @@ describe("createReplyDispatcher", () => {
});
it("fires onIdle when the queue drains", async () => {
const deliver = vi.fn(
async () => await new Promise((resolve) => setTimeout(resolve, 5)),
);
const deliver = vi.fn(async () => await new Promise((resolve) => setTimeout(resolve, 5)));
const onIdle = vi.fn();
const dispatcher = createReplyDispatcher({ deliver, onIdle });

View File

@@ -5,10 +5,7 @@ import type { TypingController } from "./typing.js";
export type ReplyDispatchKind = "tool" | "block" | "final";
type ReplyDispatchErrorHandler = (
err: unknown,
info: { kind: ReplyDispatchKind },
) => void;
type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void;
type ReplyDispatchDeliverer = (
payload: ReplyPayload,
@@ -23,13 +20,9 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number {
const mode = config?.mode ?? "off";
if (mode === "off") return 0;
const min =
mode === "custom"
? (config?.minMs ?? DEFAULT_HUMAN_DELAY_MIN_MS)
: DEFAULT_HUMAN_DELAY_MIN_MS;
mode === "custom" ? (config?.minMs ?? DEFAULT_HUMAN_DELAY_MIN_MS) : DEFAULT_HUMAN_DELAY_MIN_MS;
const max =
mode === "custom"
? (config?.maxMs ?? DEFAULT_HUMAN_DELAY_MAX_MS)
: DEFAULT_HUMAN_DELAY_MAX_MS;
mode === "custom" ? (config?.maxMs ?? DEFAULT_HUMAN_DELAY_MAX_MS) : DEFAULT_HUMAN_DELAY_MAX_MS;
if (max <= min) return min;
return Math.floor(Math.random() * (max - min + 1)) + min;
}
@@ -47,10 +40,7 @@ export type ReplyDispatcherOptions = {
humanDelay?: HumanDelayConfig;
};
export type ReplyDispatcherWithTypingOptions = Omit<
ReplyDispatcherOptions,
"onIdle"
> & {
export type ReplyDispatcherWithTypingOptions = Omit<ReplyDispatcherOptions, "onIdle"> & {
onReplyStart?: () => Promise<void> | void;
onIdle?: () => void;
};
@@ -79,9 +69,7 @@ function normalizeReplyPayloadInternal(
});
}
export function createReplyDispatcher(
options: ReplyDispatcherOptions,
): ReplyDispatcher {
export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDispatcher {
let sendChain: Promise<void> = Promise.resolve();
// Track in-flight deliveries so we can emit a reliable "idle" signal.
let pending = 0;

View File

@@ -1,13 +1,7 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js";
import {
CHAT_CHANNEL_ORDER,
normalizeChannelId,
} from "../../channels/registry.js";
import type {
AgentElevatedAllowFromConfig,
ClawdbotConfig,
} from "../../config/config.js";
import { CHAT_CHANNEL_ORDER, normalizeChannelId } from "../../channels/registry.js";
import type { AgentElevatedAllowFromConfig, ClawdbotConfig } from "../../config/config.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import type { MsgContext } from "../templating.js";
@@ -64,9 +58,7 @@ function isApprovedElevatedSender(params: {
);
if (!rawAllow || rawAllow.length === 0) return false;
const allowTokens = rawAllow
.map((entry) => String(entry).trim())
.filter(Boolean);
const allowTokens = rawAllow.map((entry) => String(entry).trim()).filter(Boolean);
if (allowTokens.length === 0) return false;
if (allowTokens.some((entry) => entry === "*")) return true;
@@ -116,14 +108,12 @@ export function resolveElevatedPermissions(params: {
failures: Array<{ gate: string; key: string }>;
} {
const globalConfig = params.cfg.tools?.elevated;
const agentConfig = resolveAgentConfig(params.cfg, params.agentId)?.tools
?.elevated;
const agentConfig = resolveAgentConfig(params.cfg, params.agentId)?.tools?.elevated;
const globalEnabled = globalConfig?.enabled !== false;
const agentEnabled = agentConfig?.enabled !== false;
const enabled = globalEnabled && agentEnabled;
const failures: Array<{ gate: string; key: string }> = [];
if (!globalEnabled)
failures.push({ gate: "enabled", key: "tools.elevated.enabled" });
if (!globalEnabled) failures.push({ gate: "enabled", key: "tools.elevated.enabled" });
if (!agentEnabled)
failures.push({
gate: "enabled",
@@ -184,11 +174,7 @@ export 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>).",

View File

@@ -4,8 +4,7 @@ const INLINE_SIMPLE_COMMAND_ALIASES = new Map<string, string>([
["/whoami", "/whoami"],
["/id", "/whoami"],
]);
const INLINE_SIMPLE_COMMAND_RE =
/(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i;
const INLINE_SIMPLE_COMMAND_RE = /(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i;
const INLINE_STATUS_RE = /(?:^|\s)\/(?:status|usage)(?=$|\s|:)(?:\s*:\s*)?/gi;
@@ -29,9 +28,6 @@ export function stripInlineStatus(body: string): {
} {
const trimmed = body.trim();
if (!trimmed) return { cleaned: "", didStrip: false };
const cleaned = trimmed
.replace(INLINE_STATUS_RE, " ")
.replace(/\s+/g, " ")
.trim();
const cleaned = trimmed.replace(INLINE_STATUS_RE, " ").replace(/\s+/g, " ").trim();
return { cleaned, didStrip: cleaned !== trimmed };
}

View File

@@ -42,9 +42,7 @@ export function applyReplyTagsToPayload(
export function isRenderablePayload(payload: ReplyPayload): boolean {
return Boolean(
payload.text ||
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0),
payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0),
);
}
@@ -55,10 +53,7 @@ export function applyReplyThreading(params: {
currentMessageId?: string;
}): ReplyPayload[] {
const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
const applyReplyToMode = createReplyToModeFilterForChannel(
replyToMode,
replyToChannel,
);
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
return payloads
.map((payload) => applyReplyTagsToPayload(payload, currentMessageId))
.filter(isRenderablePayload)
@@ -71,9 +66,7 @@ export function filterMessagingToolDuplicates(params: {
}): ReplyPayload[] {
const { payloads, sentTexts } = params;
if (sentTexts.length === 0) return payloads;
return payloads.filter(
(payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts),
);
return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts));
}
function normalizeAccountId(value?: string): string | undefined {
@@ -89,10 +82,7 @@ export function shouldSuppressMessagingToolReplies(params: {
}): boolean {
const provider = params.messageProvider?.trim().toLowerCase();
if (!provider) return false;
const originTarget = normalizeTargetForProvider(
provider,
params.originatingTo,
);
const originTarget = normalizeTargetForProvider(provider, params.originatingTo);
if (!originTarget) return false;
const originAccount = normalizeAccountId(params.accountId);
const sentTargets = params.messagingToolSentTargets ?? [];

View File

@@ -1,10 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import {
createReplyToModeFilter,
resolveReplyToMode,
} from "./reply-threading.js";
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
const emptyCfg = {} as ClawdbotConfig;
@@ -44,9 +41,7 @@ describe("createReplyToModeFilter", () => {
it("keeps replyToId when mode is off and reply tags are allowed", () => {
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
expect(
filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId,
).toBe("1");
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
});
it("keeps replyToId when mode is all", () => {

View File

@@ -131,11 +131,7 @@ describe("routeReply", () => {
sessionKey: "agent:rich:main",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.any(Object),
);
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
});
it("passes thread id to Telegram sends", async () => {

View File

@@ -52,11 +52,8 @@ export type RouteReplyResult = {
* back to the originating channel when OriginatingChannel/OriginatingTo
* are set.
*/
export async function routeReply(
params: RouteReplyParams,
): Promise<RouteReplyResult> {
const { payload, channel, to, accountId, threadId, cfg, abortSignal } =
params;
export async function routeReply(params: RouteReplyParams): Promise<RouteReplyResult> {
const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params;
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
const responsePrefix = params.sessionKey
@@ -106,9 +103,7 @@ export async function routeReply(
try {
// Provider docking: this is an execution boundary (we're about to send).
// Keep the module cheap to import by loading outbound plumbing lazily.
const { deliverOutboundPayloads } = await import(
"../../infra/outbound/deliver.js"
);
const { deliverOutboundPayloads } = await import("../../infra/outbound/deliver.js");
const results = await deliverOutboundPayloads({
cfg,
channel: channelId,

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import {
enqueueSystemEvent,
resetSystemEventsForTest,
} from "../../infra/system-events.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => {

View File

@@ -156,13 +156,7 @@ export async function incrementCompactionCount(params: {
storePath?: string;
now?: number;
}): Promise<number | undefined> {
const {
sessionEntry,
sessionStore,
sessionKey,
storePath,
now = Date.now(),
} = params;
const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now() } = params;
if (!sessionStore || !sessionKey) return undefined;
const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) return undefined;

View File

@@ -10,9 +10,7 @@ import { initSessionState } from "./session.js";
describe("initSessionState thread forking", () => {
it("forks a new session from the parent session file", async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-thread-session-"),
);
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-thread-session-"));
const sessionsDir = path.join(root, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
@@ -84,9 +82,7 @@ describe("initSessionState thread forking", () => {
});
it("records topic-specific session files when MessageThreadId is present", async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-topic-session-"),
);
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-topic-session-"));
const storePath = path.join(root, "sessions.json");
const cfg = {
@@ -134,9 +130,7 @@ describe("initSessionState RawBody", () => {
});
it("Reset triggers (/new, /reset) work with RawBody", async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-rawbody-reset-"),
);
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-rawbody-reset-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;
@@ -158,9 +152,7 @@ describe("initSessionState RawBody", () => {
});
it("falls back to Body when RawBody is undefined", async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-rawbody-fallback-"),
);
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-rawbody-fallback-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;

View File

@@ -2,10 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {
CURRENT_SESSION_VERSION,
SessionManager,
} from "@mariozechner/pi-coding-agent";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js";
@@ -59,18 +56,14 @@ function forkSessionFromParent(params: {
const manager = SessionManager.open(parentSessionFile);
const leafId = manager.getLeafId();
if (leafId) {
const sessionFile =
manager.createBranchedSession(leafId) ?? manager.getSessionFile();
const sessionFile = manager.createBranchedSession(leafId) ?? manager.getSessionFile();
const sessionId = manager.getSessionId();
if (sessionFile && sessionId) return { sessionId, sessionFile };
}
const sessionId = crypto.randomUUID();
const timestamp = new Date().toISOString();
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
const sessionFile = path.join(
manager.getSessionDir(),
`${fileTimestamp}_${sessionId}.jsonl`,
);
const sessionFile = path.join(manager.getSessionDir(), `${fileTimestamp}_${sessionId}.jsonl`);
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
@@ -95,9 +88,7 @@ export async function initSessionState(params: {
// Native slash commands (Telegram/Discord/Slack) are delivered on a separate
// "slash session" key, but should mutate the target chat session.
const targetSessionKey =
ctx.CommandSource === "native"
? ctx.CommandTargetSessionKey?.trim()
: undefined;
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
const sessionCtxForState =
targetSessionKey && targetSessionKey !== ctx.SessionKey
? { ...ctx, SessionKey: targetSessionKey }
@@ -111,15 +102,11 @@ export async function initSessionState(params: {
const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS;
const idleMinutes = Math.max(
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
1,
);
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
const sessionScope = sessionCfg?.scope ?? "per-sender";
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const sessionStore: Record<string, SessionEntry> =
loadSessionStore(storePath);
const sessionStore: Record<string, SessionEntry> = loadSessionStore(storePath);
let sessionKey: string | undefined;
let sessionEntry: SessionEntry;
@@ -135,16 +122,12 @@ export async function initSessionState(params: {
let persistedModelOverride: string | undefined;
let persistedProviderOverride: string | undefined;
const groupResolution =
resolveGroupSessionKey(sessionCtxForState) ?? undefined;
const isGroup =
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
// Prefer CommandBody/RawBody (clean message) for command detection; fall back
// to Body which may contain structural context (history, sender labels).
const commandSource = ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "";
const triggerBodyNormalized = stripStructuralPrefixes(commandSource)
.trim()
.toLowerCase();
const triggerBodyNormalized = stripStructuralPrefixes(commandSource).trim().toLowerCase();
// Use CommandBody/RawBody for reset trigger matching (clean message without structural context).
const rawBody = commandSource;
@@ -169,10 +152,7 @@ export async function initSessionState(params: {
break;
}
const triggerPrefix = `${trigger} `;
if (
trimmedBody.startsWith(triggerPrefix) ||
strippedForReset.startsWith(triggerPrefix)
) {
if (trimmedBody.startsWith(triggerPrefix) || strippedForReset.startsWith(triggerPrefix)) {
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
break;
@@ -241,15 +221,10 @@ export async function initSessionState(params: {
const normalizedChannel = normalizeChannelId(channel);
const isRoomProvider = Boolean(
normalizedChannel &&
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes(
"channel",
),
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"),
);
const nextRoom =
explicitRoom ??
(isRoomProvider && subject && subject.startsWith("#")
? subject
: undefined);
explicitRoom ?? (isRoomProvider && subject && subject.startsWith("#") ? subject : undefined);
const nextSubject = nextRoom ? undefined : subject;
sessionEntry.chatType = groupResolution.chatType ?? "group";
sessionEntry.channel = channel;

View File

@@ -14,11 +14,8 @@ export async function stageSandboxMedia(params: {
workspaceDir: string;
}) {
const { ctx, sessionCtx, cfg, sessionKey, workspaceDir } = params;
const hasPathsArray =
Array.isArray(ctx.MediaPaths) && ctx.MediaPaths.length > 0;
const pathsFromArray = Array.isArray(ctx.MediaPaths)
? ctx.MediaPaths
: undefined;
const hasPathsArray = Array.isArray(ctx.MediaPaths) && ctx.MediaPaths.length > 0;
const pathsFromArray = Array.isArray(ctx.MediaPaths) ? ctx.MediaPaths : undefined;
const rawPaths =
pathsFromArray && pathsFromArray.length > 0
? pathsFromArray
@@ -86,9 +83,7 @@ export async function stageSandboxMedia(params: {
return mapped ?? value;
};
const nextMediaPaths = hasPathsArray
? rawPaths.map((p) => rewriteIfStaged(p) ?? p)
: undefined;
const nextMediaPaths = hasPathsArray ? rawPaths.map((p) => rewriteIfStaged(p) ?? p) : undefined;
if (nextMediaPaths) {
ctx.MediaPaths = nextMediaPaths;
sessionCtx.MediaPaths = nextMediaPaths;

View File

@@ -70,9 +70,7 @@ export function createTypingController(params: {
}
typingTtlTimer = setTimeout(() => {
if (!typingTimer) return;
log?.(
`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`,
);
log?.(`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`);
cleanup();
}, typingTtlMs);
};