Skip to content

Commit 665add0

Browse files
author
Sorra
committed
tests: add unit tests for progress formatting and CLI error report; export helpers for testing
1 parent 4db0eeb commit 665add0

5 files changed

Lines changed: 261 additions & 37 deletions

File tree

src/bot/cli-runner.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,12 +490,22 @@ export async function* runAddCommand(
490490
let lastEvent: AddProgressEvent | null = null;
491491

492492
try {
493-
// Yield progress events as they arrive
493+
// Yield progress events as they arrive. Do not overwrite lastEvent when
494+
// the CLI emits a progress object that lacks a `phase` field. Some CLI
495+
// implementations may emit informational objects after completion which
496+
// would otherwise clobber a previously-seen `completed` event and cause
497+
// the overall result to be considered a failure despite exitCode 0.
494498
for await (const line of stdoutIterator) {
495499
try {
496500
const event = JSON.parse(line) as AddProgressEvent;
497-
lastEvent = event;
501+
// Always yield the event for UI updates
498502
yield event;
503+
// Only update lastEvent when a phase is present and non-empty so we
504+
// retain the most recent meaningful phase (e.g. 'completed' or
505+
// 'failed').
506+
if (event && typeof event.phase === "string" && event.phase.trim() !== "") {
507+
lastEvent = event;
508+
}
499509
} catch {
500510
// Ignore lines that aren't valid JSON (e.g., error messages)
501511
}

src/index.ts

Lines changed: 157 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,40 @@ const logger = new Logger(config.LOG_LEVEL as any);
2929
/**
3030
* Format a CLI progress event into a user-friendly Discord message
3131
*/
32-
function formatProgressMessage(event: AddProgressEvent): string {
33-
switch (event.phase) {
32+
export function formatProgressMessage(event: AddProgressEvent): string {
33+
// Normalize phase: avoid printing literal 'undefined' and handle
34+
// events that do not include a phase field more helpfully.
35+
const phase = typeof event.phase === "string" && event.phase.trim() !== "" ? event.phase : undefined;
36+
37+
if (!phase) {
38+
// If the CLI provided an explanatory message, surface it as an error-like
39+
// message so users and maintainers see something actionable in the thread.
40+
if (event.message) {
41+
const m = String(event.message).trim();
42+
// Keep output reasonably sized for Discord
43+
const truncated = m.length > 1500 ? `${m.slice(0, 1500)}…` : m;
44+
// Include title or url context when available
45+
if (event.title) return `❌ ${truncated} (${event.title})`;
46+
if (event.url) return `❌ ${truncated} (<${event.url}>)`;
47+
return `❌ ${truncated}`;
48+
}
49+
50+
// No explicit message - include any small set of identifying fields so
51+
// maintainers have context instead of seeing 'undefined'.
52+
const parts: string[] = [];
53+
if (event.id !== undefined) parts.push(`id:${event.id}`);
54+
if (event.title) parts.push(`title:${event.title}`);
55+
if (event.url) parts.push(`url:${event.url}`);
56+
if (event.timestamp) parts.push(`ts:${event.timestamp}`);
57+
58+
if (parts.length > 0) {
59+
return `⏳ Processing: unknown (${parts.join(", ")})`;
60+
}
61+
62+
return `⏳ Processing: unknown event`;
63+
}
64+
65+
switch (phase) {
3466
case "downloading":
3567
return "⏳ Downloading content...";
3668
case "extracting":
@@ -42,7 +74,15 @@ function formatProgressMessage(event: AddProgressEvent): string {
4274
case "failed":
4375
return `❌ Failed: ${event.message || "Unknown error"}`;
4476
default:
45-
return `⏳ Processing: ${event.phase}`;
77+
// For unknown but present phases, include any short message text
78+
// the CLI might have provided to give more context.
79+
const base = `⏳ Processing: ${phase}`;
80+
if (event.message) {
81+
const m = String(event.message).trim();
82+
const truncated = m.length > 1200 ? `${m.slice(0, 1200)}…` : m;
83+
return `${base}\n\n${truncated}`;
84+
}
85+
return base;
4686
}
4787
}
4888

@@ -494,6 +534,10 @@ async function processUrlWithProgress(
494534
});
495535

496536
let lastPhase: string | null = null;
537+
// Once we see a terminal phase ('completed' or 'failed') we should
538+
// suppress any subsequent progress updates emitted by the CLI to avoid
539+
// confusing the user with post-completion informational objects.
540+
let terminalPhaseSeen = false;
497541
let eventCount = 0;
498542
let finalResult: AddResult | undefined;
499543

@@ -518,17 +562,29 @@ async function processUrlWithProgress(
518562
// Process yielded progress event
519563
const event = iteration.value;
520564
eventCount++;
521-
logger.info("Received CLI progress event", {
522-
messageId: message.id,
523-
url,
565+
logger.info("Received CLI progress event", {
566+
messageId: message.id,
567+
url,
524568
phase: event.phase,
525-
eventCount
569+
eventCount,
526570
});
527-
571+
572+
// If we've already observed a terminal phase, ignore any further
573+
// progress events to avoid confusing follow-up messages (some CLI
574+
// implementations emit informational objects after completion).
575+
if (terminalPhaseSeen) {
576+
logger.debug("Ignoring CLI progress event after terminal phase", {
577+
messageId: message.id,
578+
url,
579+
eventCount,
580+
});
581+
continue;
582+
}
583+
528584
// Only send update if phase changed (avoid spam)
529585
if (event.phase !== lastPhase) {
530586
lastPhase = event.phase;
531-
587+
532588
const progressMsg = formatProgressMessage(event);
533589

534590
// Always ensure URLs shown to users are wrapped in backticks to avoid embeds.
@@ -574,6 +630,16 @@ async function processUrlWithProgress(
574630
});
575631
}
576632
}
633+
634+
// If this event indicates a terminal state, mark it so we ignore
635+
// any subsequent non-actionable events.
636+
try {
637+
if (event.phase === "completed" || event.phase === "failed") {
638+
terminalPhaseSeen = true;
639+
}
640+
} catch {
641+
// ignore
642+
}
577643
}
578644
}
579645

@@ -597,28 +663,37 @@ async function processUrlWithProgress(
597663

598664
let summaryTargetThread: ThreadChannel | null = thread;
599665

600-
if (thread) {
601-
try {
602-
await thread.send(successMsg);
603-
} catch (error) {
604-
logger.warn("Failed to send final success message to thread; falling back to channel reply", {
605-
threadId: thread.id,
606-
error: error instanceof Error ? error.message : String(error),
607-
});
608-
summaryTargetThread = null;
609-
// Fallback to channel reply
666+
// If we already observed a 'completed' progress event earlier and
667+
// posted it to the thread/channel, avoid posting a duplicate final
668+
// success message. We detect this by checking the lastPhase value.
669+
const alreadyCompleted = lastPhase === "completed";
670+
671+
if (!alreadyCompleted) {
672+
if (thread) {
610673
try {
611-
await message.reply(successMsg);
612-
} catch (err) {
613-
logger.warn("Failed to send fallback success reply to channel", {
614-
messageId: message.id,
615-
error: err instanceof Error ? err.message : String(err),
674+
await thread.send(successMsg);
675+
} catch (error) {
676+
logger.warn("Failed to send final success message to thread; falling back to channel reply", {
677+
threadId: thread.id,
678+
error: error instanceof Error ? error.message : String(error),
616679
});
680+
summaryTargetThread = null;
681+
// Fallback to channel reply
682+
try {
683+
await message.reply(successMsg);
684+
} catch (err) {
685+
logger.warn("Failed to send fallback success reply to channel", {
686+
messageId: message.id,
687+
error: err instanceof Error ? err.message : String(err),
688+
});
689+
}
617690
}
691+
} else {
692+
// Fallback to message reply if no thread
693+
await message.reply(successMsg);
618694
}
619695
} else {
620-
// Fallback to message reply if no thread
621-
await message.reply(successMsg);
696+
logger.debug("Skipping duplicate final success message because completed event was already posted", { messageId: message.id, url });
622697
}
623698

624699
await sendGeneratedSummary(message, summaryTargetThread, finalResult);
@@ -658,7 +733,7 @@ async function processUrlWithProgress(
658733

659734
if (thread) {
660735
try {
661-
await thread.send(report);
736+
await postCliErrorReport(thread, report, "⚠️ CLI error encountered during processing. See attached diagnostic report.");
662737
await thread.send(errorMsg);
663738
await thread.setArchived(true).catch(() => {});
664739
} catch (sendError) {
@@ -667,7 +742,7 @@ async function processUrlWithProgress(
667742
error: sendError instanceof Error ? sendError.message : String(sendError),
668743
});
669744
try {
670-
await message.reply(report);
745+
await postCliErrorReport(message, report, "⚠️ CLI error encountered during processing. See attached diagnostic report.");
671746
await message.reply(errorMsg);
672747
} catch (replyError) {
673748
logger.warn("Failed to send fallback CLI error report reply", {
@@ -681,7 +756,7 @@ async function processUrlWithProgress(
681756
const t = await createThreadForMessage(message, `CLI error: ${new URL(url).hostname}`, 60);
682757
if (t) {
683758
try {
684-
await t.send(report);
759+
await postCliErrorReport(t, report, "⚠️ CLI error encountered during processing. See attached diagnostic report.");
685760
await t.send(errorMsg);
686761
await t.setArchived(true).catch(() => {});
687762
} catch (threadErr) {
@@ -691,7 +766,7 @@ async function processUrlWithProgress(
691766
error: threadErr instanceof Error ? threadErr.message : String(threadErr),
692767
});
693768
try {
694-
await message.reply(report);
769+
await postCliErrorReport(message, report, "⚠️ CLI error encountered during processing. See attached diagnostic report.");
695770
await message.reply(errorMsg);
696771
} catch (replyError) {
697772
logger.warn("Failed to reply with CLI error report", {
@@ -703,7 +778,7 @@ async function processUrlWithProgress(
703778
} else {
704779
// Last resort - reply in channel
705780
try {
706-
await message.reply(report);
781+
await postCliErrorReport(message, report, "⚠️ CLI error encountered during processing. See attached diagnostic report.");
707782
await message.reply(errorMsg);
708783
} catch (replyError) {
709784
logger.warn("Failed to reply with CLI error report (no thread available)", {
@@ -935,6 +1010,54 @@ async function suppressEmbedsIfPermitted(message: Message): Promise<boolean> {
9351010
const DISCORD_CONTENT_LIMIT = 1900;
9361011
const MARKDOWN_WRAP_WIDTH = 80;
9371012

1013+
/**
1014+
* Safely post a potentially large CLI diagnostic report to a Discord target.
1015+
* If the report is within the content limit, post as a normal message.
1016+
* If it exceeds the limit, post a short explanatory message and attach
1017+
* the full report as a file to avoid Discord's message length restriction.
1018+
*/
1019+
export async function postCliErrorReport(target: any, report: string, shortIntro?: string): Promise<void> {
1020+
try {
1021+
if (!report) return;
1022+
1023+
if (report.length <= DISCORD_CONTENT_LIMIT) {
1024+
if (typeof target.send === "function") {
1025+
await target.send(report);
1026+
return;
1027+
}
1028+
if (typeof target.reply === "function") {
1029+
await target.reply(report);
1030+
return;
1031+
}
1032+
return;
1033+
}
1034+
1035+
// Report too large for a single Discord message - send as attachment.
1036+
const intro = shortIntro || "Detailed CLI diagnostic attached.";
1037+
const content = `${intro}\n\n(Full report attached as cli-error-report.txt)`;
1038+
const file = { attachment: Buffer.from(report, "utf8"), name: "cli-error-report.txt" };
1039+
1040+
if (typeof target.send === "function") {
1041+
await target.send({ content, files: [file] } as any);
1042+
return;
1043+
}
1044+
if (typeof target.reply === "function") {
1045+
await target.reply({ content, files: [file] } as any);
1046+
return;
1047+
}
1048+
} catch (err) {
1049+
logger.warn("Failed to post CLI error report", { error: err instanceof Error ? err.message : String(err) });
1050+
// Best-effort fallback: try to send a truncated inline excerpt
1051+
try {
1052+
const truncated = report.slice(0, Math.max(0, DISCORD_CONTENT_LIMIT - 50)) + "…";
1053+
if (typeof target.send === "function") await target.send(truncated);
1054+
else if (typeof target.reply === "function") await target.reply(truncated);
1055+
} catch {
1056+
// ignore
1057+
}
1058+
}
1059+
}
1060+
9381061
function wrapLineAtNearestSpace(line: string, width: number): string[] {
9391062
if (line.length <= width || width <= 0) {
9401063
return [line];
@@ -1804,14 +1927,14 @@ const bot = new DiscordBot({
18041927
if (typeof message.startThread === "function") {
18051928
try {
18061929
const t = await message.startThread({ name: `CLI error: ${new URL(seed).hostname}`, autoArchiveDuration: 60 });
1807-
await t.send(report);
1930+
await postCliErrorReport(t, report, "⚠️ CLI error encountered while queueing a URL. See attached diagnostic report.");
18081931
await t.setArchived(true).catch(() => {});
18091932
} catch (threadErr) {
18101933
logger.warn("Failed to create thread for CLI queue error; falling back to reply", { error: threadErr instanceof Error ? threadErr.message : String(threadErr) });
1811-
await message.reply(report);
1934+
await postCliErrorReport(message, report, "⚠️ CLI error encountered while queueing a URL. See attached diagnostic report.");
18121935
}
18131936
} else {
1814-
await message.reply(report);
1937+
await postCliErrorReport(message, report, "⚠️ CLI error encountered while queueing a URL. See attached diagnostic report.");
18151938
}
18161939
} catch (err) {
18171940
logger.warn("Failed to post detailed CLI error report for queue command", { error: err instanceof Error ? err.message : String(err) });

tests/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,8 @@ describe("index message handler integration", () => {
335335
autoArchiveDuration: 60,
336336
});
337337
expect(thread.send).toHaveBeenCalled();
338+
// Only the progress-completed message should be posted (avoid duplicate success messages)
338339
expect(threadMessages).toContain("✅ Added to OpenBrain: `Useful Title`");
339-
expect(threadMessages).toContain("✅ Added: `Useful Title`");
340340
});
341341

342342
it("passes Discord context tags to spawn for ob add", async () => {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, it, expect } from "vitest";
2+
import { formatProgressMessage } from "../../src/index.js";
3+
4+
describe("formatProgressMessage", () => {
5+
it("handles known phases", () => {
6+
expect(formatProgressMessage({ phase: "downloading" } as any)).toBe("⏳ Downloading content...");
7+
expect(formatProgressMessage({ phase: "extracting" } as any)).toBe("📝 Extracting text content...");
8+
expect(formatProgressMessage({ phase: "embedding" } as any)).toBe("🧠 Generating embeddings...");
9+
expect(formatProgressMessage({ phase: "completed", title: "My Title" } as any)).toBe("✅ Added to OpenBrain: My Title");
10+
expect(formatProgressMessage({ phase: "failed", message: "boom" } as any)).toBe("❌ Failed: boom");
11+
});
12+
13+
it("handles missing phase with message and title/url", () => {
14+
const m1 = formatProgressMessage({ message: "Something went wrong", title: "T" } as any);
15+
expect(m1).toContain("❌");
16+
expect(m1).toContain("T");
17+
18+
const m2 = formatProgressMessage({ message: "Long msg", url: "https://x" } as any);
19+
expect(m2).toContain("❌");
20+
expect(m2).toContain("<https://x>");
21+
});
22+
23+
it("handles missing phase with no message by listing identifying fields", () => {
24+
const out = formatProgressMessage({ id: 5, title: "TT", url: "https://u" } as any);
25+
expect(out).toContain("⏳ Processing: unknown");
26+
expect(out).toContain("id:5");
27+
expect(out).toContain("title:TT");
28+
});
29+
30+
it("handles unknown phase with message truncation", () => {
31+
const long = "a".repeat(2000);
32+
const out = formatProgressMessage({ phase: "weird", message: long } as any);
33+
expect(out).toContain("Processing: weird");
34+
expect(out.length).toBeLessThan(1400);
35+
});
36+
});

0 commit comments

Comments
 (0)