Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 37 additions & 27 deletions apps/discord-bot/src/features/ResponseBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ import * as Semaphore from "effect/Semaphore";

import { DiscordBotConfig } from "../config.ts";
import {
appendT3DeepLinkToChunks,
buildStreamHistoryMarkdownText,
DISCORD_MAX_FILES_PER_MESSAGE,
imageAttachmentsOf,
shouldAttachT3DeepLink,
STREAM_HISTORY_MARKDOWN_NAME,
streamHistoryHasAdditionalContent,
unpostedAttachments,
} from "../presentation/attachments.ts";
import { buildOmegentThreadMessageUrl } from "../presentation/discordPrAttribution.ts";
import {
createMessageWithAttachments,
DiscordUploadError,
Expand Down Expand Up @@ -64,10 +67,7 @@ import {
stripMarkdownImages,
type MarkdownImageRef,
} from "../presentation/markdownImages.ts";
import {
chunkDiscordContentPreservingTables,
rewriteMarkdownTablesForDiscord,
} from "../presentation/asciiTables.ts";
import { hasMarkdownTables } from "../presentation/asciiTables.ts";
import {
chunkDiscordContent,
formatInProgressChunk,
Expand Down Expand Up @@ -3076,7 +3076,8 @@ export const runBridge = (
* - tip ends with italic _Working.._ (optional · N tool calls on the same line)
*
* On turn complete: stream messages are deleted, archived as stream-history.md,
* and the final answer is posted as normal Discord message content (+ real image files).
* and the final answer is posted as Discord markdown (links live; no ASCII tables),
* with a T3 deep link when the answer is long or has GFM tables.
*/
const postOrEditAssistantUnlocked = (args: {
readonly turnId: string | null;
Expand Down Expand Up @@ -3485,9 +3486,10 @@ export const runBridge = (

/**
* Final delivery for a completed assistant turn:
* 1. Post the final answer as normal Discord message content (chunked if needed)
* 2. Attach stream-history.md + chat image attachments + local markdown images as files
* 3. Delete the in-progress stream messages so only the final answer remains visible
* 1. Post Discord markdown content (chunked if needed; links stay clickable)
* 2. Append · [T3](…#message-…) when multi-chunk or tables (full render in Omegent)
* 3. Attach stream-history.md + chat/local images as files
* 4. Delete the in-progress stream messages so only the final answer remains visible
*/
const finalizeAssistantMessage = (args: {
readonly turnId: string | null;
Expand Down Expand Up @@ -3686,15 +3688,18 @@ export const runBridge = (

const postedFromFiles = pendingImages.slice(0, imageFiles.length).map((entry) => entry.id);

// Split once for local-file rewrite notes; re-split after optional table .txt attachments.
const initialSplit = splitFilesForDiscordUpload(files);
const oversizedByName = new Set(initialSplit.oversized.map((file) => file.name));
// Split once for local-file rewrite notes (no table .txt attachments).
const { batches: uploadBatches, oversized: oversizedFiles } =
splitFilesForDiscordUpload(files);
const oversizedByName = new Set(oversizedFiles.map((file) => file.name));
const attachedFileNames = new Set(
initialSplit.batches.flatMap((batch) => batch.map((file) => file.name)),
uploadBatches.flatMap((batch) => batch.map((file) => file.name)),
);

// Final channel text: strip image embeds but keep readable local file references.
// Never leave Working.. or the stream placeholder.
// Keep Discord markdown as-is (links stay clickable). Do not ASCII-ify tables —
// long / table-heavy answers get a T3 deep link for full rendering in Omegent.
const finalText = rewriteMarkdownLocalFileLinksForDiscord({
text: stripWorkingIndicator(stripMarkdownImages(text)),
githubUrlsBySrc,
Expand All @@ -3709,29 +3714,17 @@ export const runBridge = (
extractInlinePathCodeSpanRefs(finalText),
worktreePath,
);
const pathRewrittenFinalText = rewriteInlinePathCodeSpansForDiscord({
const renderedFinalText = rewriteInlinePathCodeSpansForDiscord({
text: finalText,
githubUrlsByToken: finalInlineGitHubUrlsByToken,
});
// Discord does not render GFM pipe tables — convert to fenced ASCII grids.
const tableRewrite = rewriteMarkdownTablesForDiscord(pathRewrittenFinalText, {
style: "rounded",
messageLimit: DISCORD_LIMIT,
});
for (const attachment of tableRewrite.attachments) {
files.push(textFile(attachment.name, attachment.body, "text/plain;charset=utf-8"));
}
const renderedFinalText = tableRewrite.text;

const { batches: uploadBatches, oversized: oversizedFiles } =
tableRewrite.attachments.length > 0 ? splitFilesForDiscordUpload(files) : initialSplit;

// Avoid posting a lone "…" placeholder (what you saw in Discord when an image-only
// turn failed to attach and had no remaining text). Prefer empty content + files,
// or a short failure note if we expected images but loaded none.
const baseFinalChunks: string[] =
renderedFinalText !== ""
? chunkDiscordContentPreservingTables(renderedFinalText, DISCORD_LIMIT)
? chunkDiscordContent(renderedFinalText, DISCORD_LIMIT)
: files.length > 0
? [""]
: pendingMarkdown.length > 0 ||
Expand All @@ -3750,7 +3743,24 @@ export const runBridge = (
turnId,
latestTurn: statsThread?.latestTurn ?? null,
});
const finalChunks = appendStatsToMessageChunks(baseFinalChunks, statsLine, DISCORD_LIMIT);
let finalChunks = appendStatsToMessageChunks(baseFinalChunks, statsLine, DISCORD_LIMIT);

// Long multi-message finals and any answer with GFM tables → · [T3](deep link).
if (
shouldAttachT3DeepLink({
text: renderedFinalText,
hasMarkdownTables: hasMarkdownTables(renderedFinalText),
messageChunkCount: finalChunks.length,
})
) {
const botConfig = yield* DiscordBotConfig;
const t3Url = buildOmegentThreadMessageUrl({
webUiBaseUrl: botConfig.webUiBaseUrl,
threadId: input.t3ThreadId,
messageId: t3MessageId,
});
finalChunks = appendT3DeepLinkToChunks(finalChunks, t3Url, DISCORD_LIMIT);
}

if (finalChunks.length === 0 && files.length === 0) {
// Nothing useful to post — just clear any leftover Working.. stream messages.
Expand Down
5 changes: 5 additions & 0 deletions apps/discord-bot/src/presentation/asciiTables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ export function extractMarkdownTables(text: string): TableMatch[] {
return matches;
}

/** True when `text` contains at least one GFM pipe table. */
export function hasMarkdownTables(text: string): boolean {
return extractMarkdownTables(text).length > 0;
}

/** Exclusive end offset for `lineIndex`, including its trailing newline when present. */
function lineEndExclusive(
text: string,
Expand Down
45 changes: 45 additions & 0 deletions apps/discord-bot/src/presentation/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, expect, it } from "vite-plus/test";

import {
appendT3DeepLinkToChunks,
attachmentKey,
buildStreamHistoryMarkdownText,
imageAttachmentsOf,
shouldAttachT3DeepLink,
STREAM_HISTORY_MARKDOWN_NAME,
streamHistoryHasAdditionalContent,
unpostedAttachments,
withT3DeepLink,
} from "./attachments.ts";

describe("imageAttachmentsOf", () => {
Expand Down Expand Up @@ -90,3 +93,45 @@ describe("buildStreamHistoryMarkdownText", () => {
expect(buildStreamHistoryMarkdownText(" \n")).toBeNull();
});
});

describe("T3 deep link caption helpers", () => {
it("appends a short same-line T3 link", () => {
expect(
withT3DeepLink("Summary", "https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1"),
).toBe("Summary · [T3](https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1)");
expect(withT3DeepLink("Summary", null)).toBe("Summary");
});

it("links when the answer has tables or needs multiple chunks", () => {
expect(
shouldAttachT3DeepLink({
text: "short",
hasMarkdownTables: false,
messageChunkCount: 1,
}),
).toBe(false);
expect(
shouldAttachT3DeepLink({
text: "long",
hasMarkdownTables: false,
messageChunkCount: 2,
}),
).toBe(true);
expect(
shouldAttachT3DeepLink({
text: "| A | B |\n|---|---|\n| 1 | 2 |",
hasMarkdownTables: true,
messageChunkCount: 1,
}),
).toBe(true);
});

it("appends the link onto the last chunk without overflowing", () => {
const url = "https://t3vm.example/?thread=t#message-m";
expect(appendT3DeepLinkToChunks(["hello"], url, 2000)).toEqual([`hello · [T3](${url})`]);
const almostFull = "x".repeat(1990);
const next = appendT3DeepLinkToChunks([almostFull], url, 2000);
expect(next).toHaveLength(2);
expect(next[1]).toBe(`[T3](${url})`);
});
});
56 changes: 56 additions & 0 deletions apps/discord-bot/src/presentation/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,59 @@ export function buildStreamHistoryMarkdownFile(streamText: string): File | null
type: "text/markdown;charset=utf-8",
});
}

/**
* Append a compact T3 deep link on a Discord caption/chunk.
* Same-line ` · [T3](url)` — clickable Discord markdown, short label.
*/
export function withT3DeepLink(caption: string, t3Url: string | null | undefined): string {
const url = t3Url?.trim() ?? "";
if (url === "") return caption;
const link = `[T3](${url})`;
const body = caption.trimEnd();
if (body === "") return link;
return `${body} · ${link}`;
}

/**
* When the final answer would need multi-message chunking or contains GFM tables,
* surface a T3 deep link so the full rendered answer is one click away.
* Short single-message prose without tables stays link-free.
*/
export function shouldAttachT3DeepLink(input: {
readonly text: string;
readonly hasMarkdownTables: boolean;
readonly messageChunkCount: number;
}): boolean {
if (input.text.trim() === "") return false;
if (input.hasMarkdownTables) return true;
return input.messageChunkCount > 1;
}

/**
* Append a T3 deep link onto the last message chunk, respecting the Discord limit.
* If the link would overflow the last chunk, emit it as its own trailing chunk.
*/
export function appendT3DeepLinkToChunks(
chunks: ReadonlyArray<string>,
t3Url: string | null | undefined,
limit: number,
): string[] {
const url = t3Url?.trim() ?? "";
if (url === "" || chunks.length === 0) return [...chunks];

const out = [...chunks];
const lastIndex = out.length - 1;
const last = out[lastIndex] ?? "";
const linked = withT3DeepLink(last, url);
if (linked.length <= limit) {
out[lastIndex] = linked;
return out;
}

const solo = withT3DeepLink("", url);
if (solo.length <= limit) {
out.push(solo);
}
return out;
}
18 changes: 18 additions & 0 deletions apps/discord-bot/src/presentation/discordPrAttribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
appendDiscordPrAttributionFooter,
buildDiscordThreadJumpUrl,
buildOmegentThreadMessageUrl,
buildT3WebThreadUrl,
DISCORD_PR_ATTRIBUTION_MARKER,
ensureDiscordPrAttributionFooters,
Expand Down Expand Up @@ -103,6 +104,23 @@ describe("T3 thread URL helpers", () => {
expect(once).toBe(`${base} · [T3](https://t3vm/?thread=1)`);
expect(withT3ThreadLink(once, "https://t3vm/?thread=1")).toBe(once);
});

it("builds message deep links from the configured web UI base", () => {
expect(
buildOmegentThreadMessageUrl({
webUiBaseUrl: "https://t3vm.tail86038f.ts.net/",
threadId: "tid-1",
messageId: "msg-1",
}),
).toBe("https://t3vm.tail86038f.ts.net/?thread=tid-1#message-msg-1");
expect(
buildOmegentThreadMessageUrl({
webUiBaseUrl: undefined,
threadId: "tid-1",
messageId: "msg-1",
}),
).toBeNull();
});
});

describe("buildDiscordThreadJumpUrl", () => {
Expand Down
26 changes: 26 additions & 0 deletions apps/discord-bot/src/presentation/discordPrAttribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,32 @@ export function buildT3WebThreadUrl(
return `${base.replace(/\/+$/u, "")}/?thread=${id}`;
}

/**
* Same web UI base as the pin's "Open in Omegent" (`T3_WEB_UI_BASE_URL`),
* plus `#message-{messageId}` for client scroll-into-view.
* Does not invent a short `t3vm` host — that rewrite is only for public PR bodies.
*/
export function buildOmegentThreadMessageUrl(input: {
readonly webUiBaseUrl?: string | null | undefined;
readonly threadId: string | undefined | null;
readonly messageId: string | undefined | null;
}): string | null {
const messageId = input.messageId?.trim() ?? "";
if (messageId === "") return null;

const threadUrl = buildT3WebThreadUrl(input.webUiBaseUrl, input.threadId);
if (threadUrl === null) return null;

try {
const url = new URL(threadUrl);
url.hash = `message-${messageId}`;
return url.toString();
} catch {
const withoutHash = threadUrl.replace(/#.*$/u, "");
return `${withoutHash}#message-${messageId}`;
}
}

/**
* Public-safe short form: same URL with hostname forced to `t3vm` (no port).
* Example: `https://t3vm.tail….ts.net/?thread=x` → `https://t3vm/?thread=x`
Expand Down
Loading
Loading