test(auto-reply): add helper coverage and docs

This commit is contained in:
Peter Steinberger
2025-11-26 02:09:50 +01:00
parent 5c8ce41e12
commit ce5b02a9ad
5 changed files with 387 additions and 17 deletions

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { WarelayConfig } from "../config/config.js";
@@ -19,6 +20,8 @@ type CommandReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"] & {
mode: "command";
};
type EnqueueRunner = typeof enqueueCommand;
type CommandReplyParams = {
reply: CommandReplyConfig;
templatingCtx: TemplateContext;
@@ -29,9 +32,25 @@ type CommandReplyParams = {
timeoutMs: number;
timeoutSeconds: number;
commandRunner: typeof runCommandWithTimeout;
enqueue?: EnqueueRunner;
};
function summarizeClaudeMetadata(payload: unknown): string | undefined {
export type CommandReplyMeta = {
durationMs: number;
queuedMs?: number;
queuedAhead?: number;
exitCode?: number | null;
signal?: string | null;
killed?: boolean;
claudeMeta?: string;
};
export type CommandReplyResult = {
payload?: ReplyPayload;
meta: CommandReplyMeta;
};
export function summarizeClaudeMetadata(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object") return undefined;
const obj = payload as Record<string, unknown>;
const parts: string[] = [];
@@ -83,7 +102,7 @@ function summarizeClaudeMetadata(payload: unknown): string | undefined {
export async function runCommandReply(
params: CommandReplyParams,
): Promise<ReplyPayload | undefined> {
): Promise<CommandReplyResult> {
const {
reply,
templatingCtx,
@@ -94,6 +113,7 @@ export async function runCommandReply(
timeoutMs,
timeoutSeconds,
commandRunner,
enqueue = enqueueCommand,
} = params;
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
@@ -167,11 +187,15 @@ export async function runCommandReply(
);
const started = Date.now();
let queuedMs: number | undefined;
let queuedAhead: number | undefined;
try {
const { stdout, stderr, code, signal, killed } = await enqueueCommand(
const { stdout, stderr, code, signal, killed } = await enqueue(
() => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }),
{
onWait: (waitMs, queuedAhead) => {
onWait: (waitMs, ahead) => {
queuedMs = waitMs;
queuedAhead = ahead;
if (isVerbose()) {
logVerbose(
`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`,
@@ -223,23 +247,86 @@ export async function runCommandReply(
console.error(
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
);
return undefined;
return {
payload: undefined,
meta: {
durationMs: Date.now() - started,
queuedMs,
queuedAhead,
exitCode: code,
signal,
killed,
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
},
};
}
if (killed && !signal) {
console.error(
`Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`,
);
return undefined;
return {
payload: undefined,
meta: {
durationMs: Date.now() - started,
queuedMs,
queuedAhead,
exitCode: code,
signal,
killed,
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
},
};
}
const mediaUrls =
let mediaUrls =
mediaFromCommand ?? (reply.mediaUrl ? [reply.mediaUrl] : undefined);
return trimmed || mediaUrls?.length
? {
text: trimmed || undefined,
mediaUrl: mediaUrls?.[0],
mediaUrls,
// If mediaMaxMb is set, skip local media paths larger than the cap.
if (mediaUrls?.length && reply.mediaMaxMb) {
const maxBytes = reply.mediaMaxMb * 1024 * 1024;
const filtered: string[] = [];
for (const url of mediaUrls) {
if (/^https?:\/\//i.test(url)) {
filtered.push(url);
continue;
}
: undefined;
const abs = path.isAbsolute(url) ? url : path.resolve(url);
try {
const stats = await fs.stat(abs);
if (stats.size <= maxBytes) {
filtered.push(url);
} else if (isVerbose()) {
logVerbose(
`Skipping media ${url} (${(stats.size / (1024 * 1024)).toFixed(2)}MB) over cap ${reply.mediaMaxMb}MB`,
);
}
} catch {
filtered.push(url);
}
}
mediaUrls = filtered;
}
const payload =
trimmed || mediaUrls?.length
? {
text: trimmed || undefined,
mediaUrl: mediaUrls?.[0],
mediaUrls,
}
: undefined;
const meta: CommandReplyMeta = {
durationMs: Date.now() - started,
queuedMs,
queuedAhead,
exitCode: code,
signal,
killed,
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
};
if (isVerbose()) {
logVerbose(`Command auto-reply meta: ${JSON.stringify(meta)}`);
}
return { payload, meta };
} catch (err) {
const elapsed = Date.now() - started;
const anyErr = err as { killed?: boolean; signal?: string };
@@ -261,9 +348,29 @@ export async function runCommandReply(
const text = partialSnippet
? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}`
: baseMsg;
return { text };
return {
payload: { text },
meta: {
durationMs: elapsed,
queuedMs,
queuedAhead,
exitCode: undefined,
signal: anyErr.signal,
killed: anyErr.killed,
},
};
}
logError(`Command auto-reply failed after ${elapsed}ms: ${String(err)}`);
return undefined;
return {
payload: undefined,
meta: {
durationMs: elapsed,
queuedMs,
queuedAhead,
exitCode: undefined,
signal: anyErr.signal,
killed: anyErr.killed,
},
};
}
}