fix: stabilize ci checks

This commit is contained in:
Peter Steinberger
2026-01-19 00:34:16 +00:00
parent 2f6b5ffdfe
commit d9384785a3
35 changed files with 197 additions and 268 deletions

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
src/canvas-host/a2ui/a2ui.bundle.js

View File

@@ -424,14 +424,18 @@ public struct PollParams: Codable, Sendable {
public struct AgentParams: Codable, Sendable {
public let message: String
public let agentid: String?
public let to: String?
public let replyto: String?
public let sessionid: String?
public let sessionkey: String?
public let thinking: String?
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let channel: String?
public let replychannel: String?
public let accountid: String?
public let replyaccountid: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -441,14 +445,18 @@ public struct AgentParams: Codable, Sendable {
public init(
message: String,
agentid: String?,
to: String?,
replyto: String?,
sessionid: String?,
sessionkey: String?,
thinking: String?,
deliver: Bool?,
attachments: [AnyCodable]?,
channel: String?,
replychannel: String?,
accountid: String?,
replyaccountid: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -457,14 +465,18 @@ public struct AgentParams: Codable, Sendable {
spawnedby: String?
) {
self.message = message
self.agentid = agentid
self.to = to
self.replyto = replyto
self.sessionid = sessionid
self.sessionkey = sessionkey
self.thinking = thinking
self.deliver = deliver
self.attachments = attachments
self.channel = channel
self.replychannel = replychannel
self.accountid = accountid
self.replyaccountid = replyaccountid
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -474,14 +486,18 @@ public struct AgentParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case message
case agentid = "agentId"
case to
case replyto = "replyTo"
case sessionid = "sessionId"
case sessionkey = "sessionKey"
case thinking
case deliver
case attachments
case channel
case replychannel = "replyChannel"
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"

View File

@@ -22,8 +22,7 @@ import { getApiKeyForModel } from "./model-auth.js";
import { normalizeProviderId, parseModelRef } from "./model-selection.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";

View File

@@ -22,11 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
import { createClawdbotTools } from "./clawdbot-tools.js";
const waitForCalls = async (
getCount: () => number,
count: number,
timeoutMs = 2000,
) => {
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
const start = Date.now();
while (getCount() < count) {
if (Date.now() - start > timeoutMs) {
@@ -254,18 +250,9 @@ describe("sessions tools", () => {
runId: "run-1",
delivery: { status: "pending", mode: "announce" },
});
await waitForCalls(
() => calls.filter((call) => call.method === "agent").length,
4,
);
await waitForCalls(
() => calls.filter((call) => call.method === "agent.wait").length,
4,
);
await waitForCalls(
() => calls.filter((call) => call.method === "chat.history").length,
4,
);
await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 4);
await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 4);
await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 4);
const waitPromise = tool.execute("call6", {
sessionKey: "main",
@@ -279,18 +266,9 @@ describe("sessions tools", () => {
delivery: { status: "pending", mode: "announce" },
});
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await waitForCalls(
() => calls.filter((call) => call.method === "agent").length,
8,
);
await waitForCalls(
() => calls.filter((call) => call.method === "agent.wait").length,
8,
);
await waitForCalls(
() => calls.filter((call) => call.method === "chat.history").length,
8,
);
await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 8);
await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 8);
await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 8);
const agentCalls = calls.filter((call) => call.method === "agent");
const waitCalls = calls.filter((call) => call.method === "agent.wait");

View File

@@ -3,8 +3,7 @@ import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
const LIVE =
isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const describeLive = LIVE && GEMINI_KEY ? describe : describe.skip;

View File

@@ -5,8 +5,7 @@ import { isTruthyEnvValue } from "../infra/env.js";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE =
isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;

View File

@@ -15,8 +15,7 @@ import { getApiKeyForModel } from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim());
const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS);

View File

@@ -6,8 +6,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { applyExtraParamsToAgent } from "./pi-embedded-runner.js";
const OPENAI_KEY = process.env.OPENAI_API_KEY ?? "";
const LIVE =
isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip;

View File

@@ -18,10 +18,7 @@ import {
updateSessionStore,
} 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 {
isMarkdownCapableMessageChannel,
@@ -32,20 +29,11 @@ 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 {
createBlockReplyPayloadKey,
type BlockReplyPipeline,
} from "./block-reply-pipeline.js";
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
import { createBlockReplyPayloadKey, 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 =
@@ -108,12 +96,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")) {
@@ -137,9 +122,7 @@ export async function runAgentTurnWithFallback(params: {
if (!sanitized.trim()) return { skip: true };
return { text: sanitized, 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);
@@ -174,10 +157,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,
@@ -227,8 +207,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({
@@ -244,10 +223,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,
@@ -264,9 +240,7 @@ export async function runAgentTurnWithFallback(params: {
params.sessionCtx.Provider,
);
if (!channel) return "markdown";
return isMarkdownCapableMessageChannel(channel)
? "markdown"
: "plain";
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
})(),
bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs,
@@ -276,11 +250,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,
@@ -291,8 +261,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?.({
@@ -305,16 +274,14 @@ export async function runAgentTurnWithFallback(params: {
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
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") {
await 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;
@@ -338,22 +305,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 &&
@@ -367,35 +326,25 @@ 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)}`);
});
// Use pipeline if available (block streaming enabled), otherwise send directly
if (
params.blockStreamingEnabled &&
params.blockReplyPipeline
) {
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
params.blockReplyPipeline.enqueue(blockPayload);
} else {
// Send directly when flushing before tool execution (no streaming).
// Track sent key to avoid duplicate in final payloads.
directlySentBlockKeys.add(
createBlockReplyPayloadKey(blockPayload),
);
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
await params.opts?.onBlockReply?.(blockPayload);
}
}
@@ -456,9 +405,7 @@ export async function runAgentTurnWithFallback(params: {
};
}
if (embeddedError?.kind === "role_ordering") {
const didReset = await params.resetSessionAfterRoleOrderingConflict(
embeddedError.message,
);
const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message);
if (didReset) {
return {
kind: "final",
@@ -476,10 +423,8 @@ 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 isRoleOrderingError =
/incorrect role information|roles must alternate/i.test(message);
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
if (
isCompactionFailure &&
@@ -495,8 +440,7 @@ export async function runAgentTurnWithFallback(params: {
};
}
if (isRoleOrderingError) {
const didReset =
await params.resetSessionAfterRoleOrderingConflict(message);
const didReset = await params.resetSessionAfterRoleOrderingConflict(message);
if (didReset) {
return {
kind: "final",
@@ -523,8 +467,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 {
@@ -574,7 +517,6 @@ export async function runAgentTurnWithFallback(params: {
fallbackModel,
didLogHeartbeatStrip,
autoCompactionCompleted,
directlySentBlockKeys:
directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
};
}

View File

@@ -196,8 +196,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
durationMs: 1,
error: {
kind: "context_overflow",
message:
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
},
},
}));

View File

@@ -1,8 +1,7 @@
import { describe, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const CDP_URL = process.env.CLAWDBOT_LIVE_BROWSER_CDP_URL?.trim() || "";
const describeLive = LIVE && CDP_URL ? describe : describe.skip;

View File

@@ -180,7 +180,7 @@ async function promptDiscordAllowFrom(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultDiscordAccountId(params.cfg);
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
const token = resolved.token;
@@ -249,9 +249,7 @@ async function promptDiscordAllowFrom(params: {
continue;
}
const ids = results.map((res) => res.id as string);
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
const unique = [...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids])];
return setDiscordAllowFrom(params.cfg, unique);
}
}

View File

@@ -80,7 +80,7 @@ async function promptIMessageAllowFrom(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultIMessageAccountId(params.cfg);
const resolved = resolveIMessageAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];

View File

@@ -85,7 +85,7 @@ async function promptSignalAllowFrom(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultSignalAccountId(params.cfg);
const resolved = resolveSignalAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];

View File

@@ -232,7 +232,7 @@ async function promptSlackAllowFrom(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultSlackAccountId(params.cfg);
const resolved = resolveSlackAccount({ cfg: params.cfg, accountId });
const token = resolved.config.userToken ?? resolved.config.botToken ?? "";
@@ -299,9 +299,7 @@ async function promptSlackAllowFrom(params: {
continue;
}
const ids = results.map((res) => res.id as string);
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
const unique = [...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids])];
return setSlackAllowFrom(params.cfg, unique);
}
}

View File

@@ -80,9 +80,10 @@ async function promptTelegramAllowFrom(params: {
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
const res = await fetch(url);
const data = (await res.json().catch(() => null)) as
| { ok?: boolean; result?: { id?: number | string } }
| null;
const data = (await res.json().catch(() => null)) as {
ok?: boolean;
result?: { id?: number | string };
} | null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
@@ -164,7 +165,7 @@ async function promptTelegramAllowFromForAccount(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultTelegramAccountId(params.cfg);
return promptTelegramAllowFrom({
cfg: params.cfg,

View File

@@ -17,16 +17,9 @@ describe("argv helpers", () => {
});
it("extracts command path ignoring flags and terminator", () => {
expect(getCommandPath(["node", "clawdbot", "status", "--json"], 2)).toEqual([
"status",
]);
expect(getCommandPath(["node", "clawdbot", "agents", "list"], 2)).toEqual([
"agents",
"list",
]);
expect(getCommandPath(["node", "clawdbot", "status", "--", "ignored"], 2)).toEqual([
"status",
]);
expect(getCommandPath(["node", "clawdbot", "status", "--json"], 2)).toEqual(["status"]);
expect(getCommandPath(["node", "clawdbot", "agents", "list"], 2)).toEqual(["agents", "list"]);
expect(getCommandPath(["node", "clawdbot", "status", "--", "ignored"], 2)).toEqual(["status"]);
});
it("returns primary command", () => {

View File

@@ -87,37 +87,34 @@ describe("gateway SIGTERM", () => {
const out: string[] = [];
const err: string[] = [];
const bunBin = process.env.BUN_INSTALL
? path.join(process.env.BUN_INSTALL, "bin", "bun")
: "bun";
const nodeBin = process.execPath;
const args = [
"--import",
"tsx",
"src/entry.ts",
"gateway",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
];
child = spawn(
bunBin,
[
"src/entry.ts",
"gateway",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
],
{
cwd: process.cwd(),
env: {
...process.env,
CLAWDBOT_STATE_DIR: stateDir,
CLAWDBOT_CONFIG_PATH: configPath,
CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1",
// Avoid port collisions with other test processes that may also start a bridge server.
CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
CLAWDBOT_BRIDGE_PORT: "0",
},
stdio: ["ignore", "pipe", "pipe"],
child = spawn(nodeBin, args, {
cwd: process.cwd(),
env: {
...process.env,
CLAWDBOT_STATE_DIR: stateDir,
CLAWDBOT_CONFIG_PATH: configPath,
CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1",
// Avoid port collisions with other test processes that may also start a bridge server.
CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
CLAWDBOT_BRIDGE_PORT: "0",
},
);
stdio: ["ignore", "pipe", "pipe"],
});
const proc = child;
if (!proc) throw new Error("failed to spawn gateway");

View File

@@ -149,9 +149,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
`(requested: ${status.requestedProvider})`,
)}`,
`${label("Model")} ${info(status.model)}`,
status.sources?.length
? `${label("Sources")} ${info(status.sources.join(", "))}`
: null,
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(status.dbPath)}`,
@@ -222,9 +220,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
status.cache.enabled && typeof status.cache.entries === "number"
? ` (${status.cache.entries} entries)`
: "";
lines.push(
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`,
);
lines.push(`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`);
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
}

View File

@@ -33,11 +33,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
"Run the embedded agent locally (requires model provider API keys in your shell)",
false,
)
.option(
"--deliver",
"Send the agent's reply back to the selected channel",
false,
)
.option("--deliver", "Send the agent's reply back to the selected channel", false)
.option("--json", "Output result as JSON", false)
.option(
"--timeout <seconds>",

View File

@@ -78,4 +78,4 @@ describe("registerSubCliCommands", () => {
expect(registerNodesCli).toHaveBeenCalledTimes(1);
expect(nodesAction).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -51,7 +51,6 @@ export type AgentCliOpts = {
local?: boolean;
};
function parseTimeoutSeconds(opts: { cfg: ReturnType<typeof loadConfig>; timeout?: string }) {
const raw =
opts.timeout !== undefined

View File

@@ -586,13 +586,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
// module scope. `CLAWDBOT_CONFIG_PATH` (and friends) are expected to work even
// when set after the module has been imported (tests, one-off scripts, etc.).
const DEFAULT_CONFIG_CACHE_MS = 200;
let configCache:
| {
configPath: string;
expiresAt: number;
config: ClawdbotConfig;
}
| null = null;
let configCache: {
configPath: string;
expiresAt: number;
config: ClawdbotConfig;
} | null = null;
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.CLAWDBOT_CONFIG_CACHE_MS?.trim();

View File

@@ -12,8 +12,7 @@ import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
import { startGatewayServer } from "./server.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const CLI_LIVE = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_CLI_BACKEND);
const CLI_IMAGE = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_PROBE);
const CLI_RESUME = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE);

View File

@@ -31,8 +31,7 @@ import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
import { startGatewayServer } from "./server.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const GATEWAY_LIVE = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_GATEWAY);
const ZAI_FALLBACK = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_GATEWAY_ZAI_FALLBACK);
const PROVIDERS = parseFilter(process.env.CLAWDBOT_LIVE_GATEWAY_PROVIDERS);

View File

@@ -427,11 +427,15 @@ vi.mock("../config/config.js", async () => {
}
const fileAgents =
fileConfig.agents && typeof fileConfig.agents === "object" && !Array.isArray(fileConfig.agents)
fileConfig.agents &&
typeof fileConfig.agents === "object" &&
!Array.isArray(fileConfig.agents)
? (fileConfig.agents as Record<string, unknown>)
: {};
const fileDefaults =
fileAgents.defaults && typeof fileAgents.defaults === "object" && !Array.isArray(fileAgents.defaults)
fileAgents.defaults &&
typeof fileAgents.defaults === "object" &&
!Array.isArray(fileAgents.defaults)
? (fileAgents.defaults as Record<string, unknown>)
: {};
const defaults = {
@@ -449,7 +453,9 @@ vi.mock("../config/config.js", async () => {
: undefined;
const fileChannels =
fileConfig.channels && typeof fileConfig.channels === "object" && !Array.isArray(fileConfig.channels)
fileConfig.channels &&
typeof fileConfig.channels === "object" &&
!Array.isArray(fileConfig.channels)
? ({ ...(fileConfig.channels as Record<string, unknown>) } as Record<string, unknown>)
: {};
const overrideChannels =
@@ -459,7 +465,9 @@ vi.mock("../config/config.js", async () => {
const mergedChannels = { ...fileChannels, ...overrideChannels };
if (testState.allowFrom !== undefined) {
const existing =
mergedChannels.whatsapp && typeof mergedChannels.whatsapp === "object" && !Array.isArray(mergedChannels.whatsapp)
mergedChannels.whatsapp &&
typeof mergedChannels.whatsapp === "object" &&
!Array.isArray(mergedChannels.whatsapp)
? (mergedChannels.whatsapp as Record<string, unknown>)
: {};
mergedChannels.whatsapp = {
@@ -470,18 +478,23 @@ vi.mock("../config/config.js", async () => {
const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined;
const fileSession =
fileConfig.session && typeof fileConfig.session === "object" && !Array.isArray(fileConfig.session)
fileConfig.session &&
typeof fileConfig.session === "object" &&
!Array.isArray(fileConfig.session)
? (fileConfig.session as Record<string, unknown>)
: {};
const session: Record<string, unknown> = {
...fileSession,
mainKey: fileSession.mainKey ?? "main",
};
if (typeof testState.sessionStorePath === "string") session.store = testState.sessionStorePath;
if (typeof testState.sessionStorePath === "string")
session.store = testState.sessionStorePath;
if (testState.sessionConfig) Object.assign(session, testState.sessionConfig);
const fileGateway =
fileConfig.gateway && typeof fileConfig.gateway === "object" && !Array.isArray(fileConfig.gateway)
fileConfig.gateway &&
typeof fileConfig.gateway === "object" &&
!Array.isArray(fileConfig.gateway)
? ({ ...(fileConfig.gateway as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind;
@@ -489,14 +502,16 @@ vi.mock("../config/config.js", async () => {
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
const fileCanvasHost =
fileConfig.canvasHost && typeof fileConfig.canvasHost === "object" && !Array.isArray(fileConfig.canvasHost)
fileConfig.canvasHost &&
typeof fileConfig.canvasHost === "object" &&
!Array.isArray(fileConfig.canvasHost)
? ({ ...(fileConfig.canvasHost as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (typeof testState.canvasHostPort === "number") fileCanvasHost.port = testState.canvasHostPort;
if (typeof testState.canvasHostPort === "number")
fileCanvasHost.port = testState.canvasHostPort;
const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined;
const hooks =
testState.hooksConfig ?? (fileConfig.hooks as HooksConfig | undefined);
const hooks = testState.hooksConfig ?? (fileConfig.hooks as HooksConfig | undefined);
const fileCron =
fileConfig.cron && typeof fileConfig.cron === "object" && !Array.isArray(fileConfig.cron)

View File

@@ -93,7 +93,8 @@ const parseUsageEntry = (entry: Record<string, unknown>): ParsedUsageEntry | nul
const role = message?.role;
if (role !== "assistant") return null;
const usageRaw = (message?.usage as UsageLike | undefined) ?? (entry.usage as UsageLike | undefined);
const usageRaw =
(message?.usage as UsageLike | undefined) ?? (entry.usage as UsageLike | undefined);
const usage = normalizeUsage(usageRaw);
if (!usage) return null;
@@ -123,10 +124,7 @@ const applyUsageTotals = (totals: CostUsageTotals, usage: NormalizedUsage) => {
totals.cacheWrite += usage.cacheWrite ?? 0;
const totalTokens =
usage.total ??
(usage.input ?? 0) +
(usage.output ?? 0) +
(usage.cacheRead ?? 0) +
(usage.cacheWrite ?? 0);
(usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
totals.totalTokens += totalTokens;
};

View File

@@ -45,10 +45,7 @@ async function main() {
{ startGatewayServer },
{ setGatewayWsLogStyle },
{ setVerbose },
{
consumeGatewaySigusr1RestartAuthorization,
isGatewaySigusr1RestartExternallyAllowed,
},
{ consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed },
{ defaultRuntime },
{ enableConsoleCapture, setConsoleTimestampPrefix },
] = await Promise.all([

View File

@@ -75,7 +75,7 @@ describe("loadClawdbotPlugins", () => {
expect(enabled?.status).toBe("loaded");
});
it("loads bundled telegram plugin when enabled", () => {
it("loads bundled telegram plugin when enabled", { timeout: 120_000 }, () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = path.join(process.cwd(), "extensions");
const registry = loadClawdbotPlugins({

View File

@@ -327,18 +327,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
const pluginSdkAlias = resolvePluginSdkAlias();
const jiti = createJiti(import.meta.url, {
interopDefault: true,
extensions: [
".ts",
".tsx",
".mts",
".cts",
".mtsx",
".ctsx",
".js",
".mjs",
".cjs",
".json",
],
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}),
});
@@ -393,9 +382,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
try {
mod = jiti(candidate.source) as ClawdbotPluginModule;
} catch (err) {
logger.error(
`[plugins] ${record.id} failed to load from ${record.source}: ${String(err)}`,
);
logger.error(`[plugins] ${record.id} failed to load from ${record.source}: ${String(err)}`);
record.status = "error";
record.error = String(err);
registry.plugins.push(record);
@@ -480,9 +467,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
});
if (!validatedConfig.ok) {
logger.error(
`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`,
);
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
record.status = "error";
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
registry.plugins.push(record);

View File

@@ -28,10 +28,9 @@ export const normalizeAllowFromWithStore = (params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: string[];
}): NormalizedAllowFrom => {
const combined = [
...(params.allowFrom ?? []),
...(params.storeAllowFrom ?? []),
].map((value) => String(value).trim()).filter(Boolean);
const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])]
.map((value) => String(value).trim())
.filter(Boolean);
return normalizeAllowFrom(combined);
};

View File

@@ -13,8 +13,16 @@ const mockTheme: SearchableSelectListTheme = {
};
const testItems = [
{ value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" },
{ value: "anthropic/claude-3-sonnet", label: "anthropic/claude-3-sonnet", description: "Claude 3 Sonnet" },
{
value: "anthropic/claude-3-opus",
label: "anthropic/claude-3-opus",
description: "Claude 3 Opus",
},
{
value: "anthropic/claude-3-sonnet",
label: "anthropic/claude-3-sonnet",
description: "Claude 3 Sonnet",
},
{ value: "openai/gpt-4", label: "openai/gpt-4", description: "GPT-4" },
{ value: "openai/gpt-4-turbo", label: "openai/gpt-4-turbo", description: "GPT-4 Turbo" },
{ value: "google/gemini-pro", label: "google/gemini-pro", description: "Gemini Pro" },
@@ -50,7 +58,11 @@ describe("SearchableSelectList", () => {
const items = [
{ value: "openrouter/auto", label: "openrouter/auto", description: "Routes to best" },
{ value: "opus-direct", label: "opus-direct", description: "Direct opus model" },
{ value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" },
{
value: "anthropic/claude-3-opus",
label: "anthropic/claude-3-opus",
description: "Claude 3 Opus",
},
];
const list = new SearchableSelectList(items, 5, mockTheme);
@@ -66,7 +78,11 @@ describe("SearchableSelectList", () => {
it("exact label match beats description match", () => {
const items = [
{ value: "provider/other", label: "provider/other", description: "This mentions opus in description" },
{
value: "provider/other",
label: "provider/other",
description: "This mentions opus in description",
},
{ value: "provider/opus-model", label: "provider/opus-model", description: "Something else" },
];
const list = new SearchableSelectList(items, 5, mockTheme);

View File

@@ -98,7 +98,11 @@ export class SearchableSelectList implements Component {
exactLabel.sort(this.compareByScore);
wordBoundary.sort(this.compareByScore);
descriptionMatches.sort(this.compareByScore);
const fuzzyMatches = fuzzyFilter(fuzzyCandidates, query, (i) => `${i.label} ${i.description ?? ""}`);
const fuzzyMatches = fuzzyFilter(
fuzzyCandidates,
query,
(i) => `${i.label} ${i.description ?? ""}`,
);
return [
...exactLabel.map((s) => s.item),
...wordBoundary.map((s) => s.item),
@@ -133,7 +137,10 @@ export class SearchableSelectList implements Component {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
private compareByScore = (a: { item: SelectItem; score: number }, b: { item: SelectItem; score: number }) => {
private compareByScore = (
a: { item: SelectItem; score: number },
b: { item: SelectItem; score: number },
) => {
if (a.score !== b.score) return a.score - b.score;
return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item));
};
@@ -190,7 +197,10 @@ export class SearchableSelectList implements Component {
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
this.filteredItems.length - this.maxVisible,
),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
@@ -211,7 +221,12 @@ export class SearchableSelectList implements Component {
return lines;
}
private renderItemLine(item: SelectItem, isSelected: boolean, width: number, query: string): string {
private renderItemLine(
item: SelectItem,
isSelected: boolean,
width: number,
query: string,
): string {
const prefix = isSelected ? "→ " : " ";
const prefixWidth = prefix.length;
const displayValue = this.getItemLabel(item);

View File

@@ -7,7 +7,11 @@ import {
import { normalizeAgentId } from "../routing/session-key.js";
import { helpText, parseCommand } from "./commands.js";
import type { ChatLog } from "./components/chat-log.js";
import { createSearchableSelectList, createSelectList, createSettingsList } from "./components/selectors.js";
import {
createSearchableSelectList,
createSelectList,
createSettingsList,
} from "./components/selectors.js";
import type { GatewayChatClient } from "./gateway-chat.js";
import { formatStatusSummary } from "./tui-status-summary.js";
import type {

View File

@@ -22,10 +22,7 @@ import { editorTheme, theme } from "./theme/theme.js";
import { createCommandHandlers } from "./tui-command-handlers.js";
import { createEventHandlers } from "./tui-event-handlers.js";
import { formatTokens } from "./tui-formatters.js";
import {
buildWaitingStatusMessage,
defaultWaitingPhrases,
} from "./tui-waiting.js";
import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js";
import { createOverlayHandlers } from "./tui-overlays.js";
import { createSessionActions } from "./tui-session-actions.js";
import type {
@@ -335,8 +332,7 @@ export async function runTui(opts: TuiOptions) {
// Pick a phrase once per waiting session.
if (!waitingPhrase) {
const idx = Math.floor(Math.random() * defaultWaitingPhrases.length);
waitingPhrase =
defaultWaitingPhrases[idx] ?? defaultWaitingPhrases[0] ?? "waiting";
waitingPhrase = defaultWaitingPhrases[idx] ?? defaultWaitingPhrases[0] ?? "waiting";
}
waitingTick = 0;