fix: unify inbound dispatch pipeline

This commit is contained in:
Peter Steinberger
2026-01-23 22:51:37 +00:00
parent da26954dd0
commit 2e0a835e07
29 changed files with 543 additions and 297 deletions

View File

@@ -2,30 +2,18 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { ensureAgentWorkspace } from "../../agents/workspace.js";
import { isControlCommandMessage } from "../../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../../auto-reply/commands-registry.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import { buildCommandContext, handleCommands } from "../../auto-reply/reply/commands.js";
import { parseInlineDirectives } from "../../auto-reply/reply/directive-handling.js";
import { defaultGroupActivation } from "../../auto-reply/reply/groups.js";
import { resolveContextTokens } from "../../auto-reply/reply/model-selection.js";
import { resolveElevatedPermissions } from "../../auto-reply/reply/reply-elevated.js";
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import {
normalizeElevatedLevel,
normalizeReasoningLevel,
normalizeThinkLevel,
normalizeVerboseLevel,
} from "../../auto-reply/thinking.js";
extractShortModelName,
type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js";
import type { MsgContext } from "../../auto-reply/templating.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { isAcpSessionKey } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import {
@@ -53,7 +41,144 @@ import {
} from "../session-utils.js";
import { stripEnvelopeFromMessages } from "../chat-sanitize.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers } from "./types.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
type TranscriptAppendResult = {
ok: boolean;
messageId?: string;
message?: Record<string, unknown>;
error?: string;
};
function resolveTranscriptPath(params: {
sessionId: string;
storePath: string | undefined;
sessionFile?: string;
}): string | null {
const { sessionId, storePath, sessionFile } = params;
if (sessionFile) return sessionFile;
if (!storePath) return null;
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
}
function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {
ok: boolean;
error?: string;
} {
if (fs.existsSync(params.transcriptPath)) return { ok: true };
try {
fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true });
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: params.sessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
};
fs.writeFileSync(params.transcriptPath, `${JSON.stringify(header)}\n`, "utf-8");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
function appendAssistantTranscriptMessage(params: {
message: string;
label?: string;
sessionId: string;
storePath: string | undefined;
sessionFile?: string;
createIfMissing?: boolean;
}): TranscriptAppendResult {
const transcriptPath = resolveTranscriptPath({
sessionId: params.sessionId,
storePath: params.storePath,
sessionFile: params.sessionFile,
});
if (!transcriptPath) {
return { ok: false, error: "transcript path not resolved" };
}
if (!fs.existsSync(transcriptPath)) {
if (!params.createIfMissing) {
return { ok: false, error: "transcript file not found" };
}
const ensured = ensureTranscriptFile({
transcriptPath,
sessionId: params.sessionId,
});
if (!ensured.ok) {
return { ok: false, error: ensured.error ?? "failed to create transcript file" };
}
}
const now = Date.now();
const messageId = randomUUID().slice(0, 8);
const labelPrefix = params.label ? `[${params.label}]\n\n` : "";
const messageBody: Record<string, unknown> = {
role: "assistant",
content: [{ type: "text", text: `${labelPrefix}${params.message}` }],
timestamp: now,
stopReason: "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
const transcriptEntry = {
type: "message",
id: messageId,
timestamp: new Date(now).toISOString(),
message: messageBody,
};
try {
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
return { ok: true, messageId, message: transcriptEntry.message };
}
function nextChatSeq(context: { agentRunSeq: Map<string, number> }, runId: string) {
const next = (context.agentRunSeq.get(runId) ?? 0) + 1;
context.agentRunSeq.set(runId, next);
return next;
}
function broadcastChatFinal(params: {
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
runId: string;
sessionKey: string;
message?: Record<string, unknown>;
}) {
const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId);
const payload = {
runId: params.runId,
sessionKey: params.sessionKey,
seq,
state: "final" as const,
message: params.message,
};
params.context.broadcast("chat", payload);
params.context.nodeSendToSession(params.sessionKey, "chat", payload);
}
function broadcastChatError(params: {
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
runId: string;
sessionKey: string;
errorMessage?: string;
}) {
const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId);
const payload = {
runId: params.runId,
sessionKey: params.sessionKey,
seq,
state: "error" as const,
errorMessage: params.errorMessage,
};
params.context.broadcast("chat", payload);
params.context.nodeSendToSession(params.sessionKey, "chat", payload);
}
export const chatHandlers: GatewayRequestHandlers = {
"chat.history": async ({ params, respond, context }) => {
@@ -168,7 +293,7 @@ export const chatHandlers: GatewayRequestHandlers = {
runIds: res.aborted ? [runId] : [],
});
},
"chat.send": async ({ params, respond, context }) => {
"chat.send": async ({ params, respond, context, client }) => {
if (!validateChatSendParams(params)) {
respond(
false,
@@ -228,20 +353,13 @@ export const chatHandlers: GatewayRequestHandlers = {
return;
}
}
const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey);
const { cfg, entry } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
});
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
const sessionEntry = mergeSessionEntry(entry, {
sessionId,
updatedAt: now,
});
store[canonicalKey] = sessionEntry;
const clientRunId = p.idempotencyKey;
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
const sendPolicy = resolveSendPolicy({
cfg,
@@ -298,21 +416,11 @@ export const chatHandlers: GatewayRequestHandlers = {
const abortController = new AbortController();
context.chatAbortControllers.set(clientRunId, {
controller: abortController,
sessionId,
sessionId: entry?.sessionId ?? clientRunId,
sessionKey: p.sessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
});
context.addChatRun(clientRunId, {
sessionKey: p.sessionKey,
clientRunId,
});
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[canonicalKey] = sessionEntry;
});
}
const ackPayload = {
runId: clientRunId,
@@ -320,170 +428,116 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
if (isControlCommandMessage(parsedMessage, cfg)) {
try {
const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1";
const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg });
const agentCfg = cfg.agents?.defaults;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const workspace = await ensureAgentWorkspace({
dir: workspaceDir,
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
});
const ctx: MsgContext = {
Body: parsedMessage,
CommandBody: parsedMessage,
BodyForCommands: parsedMessage,
CommandSource: "text",
CommandAuthorized: true,
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: "tui",
From: p.sessionKey,
To: INTERNAL_MESSAGE_CHANNEL,
SessionKey: p.sessionKey,
ChatType: "direct",
};
const command = buildCommandContext({
ctx,
cfg,
agentId,
sessionKey: p.sessionKey,
isGroup: false,
triggerBodyNormalized: normalizeCommandBody(parsedMessage),
commandAuthorized: true,
});
const directives = parseInlineDirectives(parsedMessage);
const { provider, model } = resolveSessionModelRef(cfg, sessionEntry);
const contextTokens = resolveContextTokens({ agentCfg, model });
const resolveDefaultThinkingLevel = async () => {
const configured = agentCfg?.thinkingDefault;
if (configured) return configured;
const catalog = await context.loadGatewayModelCatalog();
return resolveThinkingDefault({ cfg, provider, model, catalog });
};
const resolvedThinkLevel =
normalizeThinkLevel(sessionEntry?.thinkingLevel ?? agentCfg?.thinkingDefault) ??
(await resolveDefaultThinkingLevel());
const resolvedVerboseLevel =
normalizeVerboseLevel(sessionEntry?.verboseLevel ?? agentCfg?.verboseDefault) ?? "off";
const resolvedReasoningLevel =
normalizeReasoningLevel(sessionEntry?.reasoningLevel) ?? "off";
const resolvedElevatedLevel = normalizeElevatedLevel(
sessionEntry?.elevatedLevel ?? agentCfg?.elevatedDefault,
);
const elevated = resolveElevatedPermissions({
cfg,
agentId,
ctx,
provider: INTERNAL_MESSAGE_CHANNEL,
});
const commandResult = await handleCommands({
ctx,
cfg,
command,
agentId,
directives,
elevated,
sessionEntry,
previousSessionEntry: entry,
sessionStore: store,
sessionKey: p.sessionKey,
storePath,
sessionScope: (cfg.session?.scope ?? "per-sender") as "per-sender" | "global",
workspaceDir: workspace.dir,
defaultGroupActivation: () => defaultGroupActivation(true),
resolvedThinkLevel,
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup: false,
});
if (!commandResult.shouldContinue) {
const text = commandResult.reply?.text ?? "";
const message = {
role: "assistant",
content: text.trim() ? [{ type: "text", text }] : [],
timestamp: Date.now(),
command: true,
};
const payload = {
const trimmedMessage = parsedMessage.trim();
const injectThinking = Boolean(
p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"),
);
const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage;
const clientInfo = client?.connect?.client;
const ctx: MsgContext = {
Body: parsedMessage,
BodyForAgent: parsedMessage,
BodyForCommands: commandBody,
RawBody: parsedMessage,
CommandBody: commandBody,
SessionKey: p.sessionKey,
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: INTERNAL_MESSAGE_CHANNEL,
OriginatingChannel: INTERNAL_MESSAGE_CHANNEL,
ChatType: "direct",
CommandAuthorized: true,
MessageSid: clientRunId,
SenderId: clientInfo?.id,
SenderName: clientInfo?.displayName,
SenderUsername: clientInfo?.displayName,
};
const agentId = resolveSessionAgentId({
sessionKey: p.sessionKey,
config: cfg,
});
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, agentId),
};
const finalReplyParts: string[] = [];
const dispatcher = createReplyDispatcher({
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
onError: (err) => {
context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);
},
deliver: async (payload, info) => {
if (info.kind !== "final") return;
const text = payload.text?.trim() ?? "";
if (!text) return;
finalReplyParts.push(text);
},
});
let agentRunStarted = false;
void dispatchInboundMessage({
ctx,
cfg,
dispatcher,
replyOptions: {
runId: clientRunId,
abortSignal: abortController.signal,
images: parsedImages.length > 0 ? parsedImages : undefined,
disableBlockStreaming: true,
onAgentRunStart: () => {
agentRunStarted = true;
},
onModelSelected: (ctx) => {
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
},
})
.then(() => {
if (!agentRunStarted) {
const combinedReply = finalReplyParts
.map((part) => part.trim())
.filter(Boolean)
.join("\n\n")
.trim();
let message: Record<string, unknown> | undefined;
if (combinedReply) {
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
p.sessionKey,
);
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
const appended = appendAssistantTranscriptMessage({
message: combinedReply,
sessionId,
storePath: latestStorePath,
sessionFile: latestEntry?.sessionFile,
createIfMissing: true,
});
if (appended.ok) {
message = appended.message;
} else {
context.logGateway.warn(
`webchat transcript append failed: ${appended.error ?? "unknown error"}`,
);
const now = Date.now();
message = {
role: "assistant",
content: [{ type: "text", text: combinedReply }],
timestamp: now,
stopReason: "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
}
}
broadcastChatFinal({
context,
runId: clientRunId,
sessionKey: p.sessionKey,
seq: 0,
state: "final" as const,
message,
};
context.broadcast("chat", payload);
context.nodeSendToSession(p.sessionKey, "chat", payload);
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,
payload: { runId: clientRunId, status: "ok" as const },
});
context.chatAbortControllers.delete(clientRunId);
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
return;
}
} catch (err) {
const payload = {
runId: clientRunId,
sessionKey: p.sessionKey,
seq: 0,
state: "error" as const,
errorMessage: formatForLog(err),
};
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
context.broadcast("chat", payload);
context.nodeSendToSession(p.sessionKey, "chat", payload);
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: false,
payload: {
runId: clientRunId,
status: "error" as const,
summary: String(err),
},
error,
});
context.chatAbortControllers.delete(clientRunId);
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
return;
}
}
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const envelopedMessage = formatInboundEnvelope({
channel: "WebChat",
from: p.sessionKey,
timestamp: now,
body: parsedMessage,
chatType: "direct",
previousTimestamp: entry?.updatedAt,
envelope: envelopeOptions,
});
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: envelopedMessage,
images: parsedImages.length > 0 ? parsedImages : undefined,
sessionId,
sessionKey: p.sessionKey,
runId: clientRunId,
thinking: p.thinking,
deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: INTERNAL_MESSAGE_CHANNEL,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
context.deps,
)
.then(() => {
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,
@@ -502,6 +556,12 @@ export const chatHandlers: GatewayRequestHandlers = {
},
error,
});
broadcastChatError({
context,
runId: clientRunId,
sessionKey: p.sessionKey,
errorMessage: String(err),
});
})
.finally(() => {
context.chatAbortControllers.delete(clientRunId);

View File

@@ -4,8 +4,8 @@ import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
agentCommand,
connectOk,
getReplyFromConfig,
installGatewayTestHooks,
onceMessage,
rpcReq,
@@ -47,7 +47,7 @@ describe("gateway server chat", () => {
async () => {
const tempDirs: string[] = [];
const { server, ws } = await startServerWithClient();
const spy = vi.mocked(agentCommand);
const spy = vi.mocked(getReplyFromConfig);
const resetSpy = () => {
spy.mockReset();
spy.mockResolvedValue(undefined);
@@ -122,8 +122,9 @@ describe("gateway server chat", () => {
let abortInFlight: Promise<unknown> | undefined;
try {
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-1");
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -155,7 +156,7 @@ describe("gateway server chat", () => {
const tick = () => {
if (spy.mock.calls.length > callsBefore) return resolve();
if (Date.now() > deadline)
return reject(new Error("timeout waiting for agentCommand"));
return reject(new Error("timeout waiting for getReplyFromConfig"));
setTimeout(tick, 5);
};
tick();
@@ -177,8 +178,9 @@ describe("gateway server chat", () => {
sessionStoreSaveDelayMs.value = 120;
resetSpy();
try {
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-save-1");
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -215,8 +217,9 @@ describe("gateway server chat", () => {
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
resetSpy();
const callsBeforeStop = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-stop-1");
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -261,7 +264,8 @@ describe("gateway server chat", () => {
const runDone = new Promise<void>((resolve) => {
resolveRun = resolve;
});
spy.mockImplementationOnce(async () => {
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-status-1");
await runDone;
});
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
@@ -294,8 +298,9 @@ describe("gateway server chat", () => {
}
expect(completed).toBe(true);
resetSpy();
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-all-1");
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -359,9 +364,9 @@ describe("gateway server chat", () => {
const agentStartedP = new Promise<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => {
spy.mockImplementationOnce(async (_ctx, opts) => {
agentStartedResolve?.();
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();

View File

@@ -6,8 +6,8 @@ import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import {
agentCommand,
connectOk,
getReplyFromConfig,
installGatewayTestHooks,
onceMessage,
rpcReq,
@@ -71,7 +71,7 @@ describe("gateway server chat", () => {
webchatWs.close();
webchatWs = undefined;
const spy = vi.mocked(agentCommand);
const spy = vi.mocked(getReplyFromConfig);
spy.mockClear();
testState.agentConfig = { timeoutSeconds: 123 };
const callsBeforeTimeout = spy.mock.calls.length;
@@ -83,8 +83,8 @@ describe("gateway server chat", () => {
expect(timeoutRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBeforeTimeout);
const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined;
expect(timeoutCall?.timeout).toBe("123");
const timeoutCall = spy.mock.calls.at(-1)?.[1] as { runId?: string } | undefined;
expect(timeoutCall?.runId).toBe("idem-timeout-1");
testState.agentConfig = undefined;
spy.mockClear();
@@ -97,8 +97,8 @@ describe("gateway server chat", () => {
expect(sessionRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBeforeSession);
const sessionCall = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined;
expect(sessionCall?.sessionKey).toBe("agent:main:subagent:abc");
const sessionCall = spy.mock.calls.at(-1)?.[0] as { SessionKey?: string } | undefined;
expect(sessionCall?.SessionKey).toBe("agent:main:subagent:abc");
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(sendPolicyDir);
@@ -203,10 +203,10 @@ describe("gateway server chat", () => {
expect(imgRes.payload?.runId).toBeDefined();
await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000);
const imgCall = spy.mock.calls.at(-1)?.[0] as
const imgOpts = spy.mock.calls.at(-1)?.[1] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(historyDir);

View File

@@ -166,6 +166,7 @@ const hoisted = vi.hoisted(() => ({
waitCalls: [] as string[],
waitResults: new Map<string, boolean>(),
},
getReplyFromConfig: vi.fn().mockResolvedValue(undefined),
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
}));
@@ -197,6 +198,7 @@ export const testTailnetIPv4 = hoisted.testTailnetIPv4;
export const piSdkMock = hoisted.piSdkMock;
export const cronIsolatedRun = hoisted.cronIsolatedRun;
export const agentCommand = hoisted.agentCommand;
export const getReplyFromConfig = hoisted.getReplyFromConfig;
export const testState = {
agentConfig: undefined as Record<string, unknown> | undefined,
@@ -540,6 +542,9 @@ vi.mock("../channels/web/index.js", async () => {
vi.mock("../commands/agent.js", () => ({
agentCommand,
}));
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig,
}));
vi.mock("../cli/deps.js", async () => {
const actual = await vi.importActual<typeof import("../cli/deps.js")>("../cli/deps.js");
const base = actual.createDefaultDeps();