Skip to content
Draft
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
10 changes: 9 additions & 1 deletion packages/junior/src/chat/runtime/reply-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { createSlackAdapterAssistantStatusSession } from "@/chat/slack/assistant
import { buildSlackReplyFooter } from "@/chat/slack/footer";
import { maybeUpdateAssistantTitle } from "@/chat/slack/assistant-thread/title";
import { appendSlackLegacyAttachmentText } from "@/chat/slack/legacy-attachments";
import { maybeRefetchSlackUnfurlAttachments } from "@/chat/slack/unfurl-fetch";
import { type ThreadArtifactsState } from "@/chat/state/artifacts";
import { lookupSlackUser } from "@/chat/slack/user";
import type { TurnContinuationRequest } from "@/chat/services/timeout-resume";
Expand Down Expand Up @@ -231,9 +232,16 @@ export function createReplyToThread(deps: ReplyExecutorDeps) {
stripLeadingSlackMentionToken:
options.explicitMention || Boolean(message.isMention),
});
const enrichedRaw = await maybeRefetchSlackUnfurlAttachments({
channelId,
threadTs,
messageTs,
originalRaw: message.raw,
text: message.text,
});
const userText = appendSlackLegacyAttachmentText(
strippedUserText,
message.raw,
enrichedRaw,
);

const preparedState =
Expand Down
99 changes: 99 additions & 0 deletions packages/junior/src/chat/slack/unfurl-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { listThreadReplies } from "@/chat/slack/channel";
import { renderSlackLegacyAttachmentText } from "@/chat/slack/legacy-attachments";

const URL_PATTERN = /\bhttps?:\/\/\S+/i;

/** Return true when the raw message object already carries attachment data. */
function hasAttachments(raw: unknown): boolean {
if (!raw || typeof raw !== "object") return false;
const attachments = (raw as Record<string, unknown>).attachments;
return Array.isArray(attachments) && attachments.length > 0;
}

/** Return true when the text contains at least one URL that Slack might unfurl. */
function containsUrl(text: string | undefined): boolean {
return URL_PATTERN.test(text ?? "");
}

/** Sleep for the given number of milliseconds. */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Attempt to enrich the raw message object with Slack unfurl attachment data
* fetched from the Slack API.
*
* Slack delivers unfurls asynchronously via `message_changed` events, so the
* original inbound `message.raw` often has an empty `attachments` array even
* when a URL preview is already visible in the Slack UI. This helper retries
* the `conversations.replies` endpoint a few times with short delays so that
* Junior can see unfurl content when constructing the user turn text.
*
* @returns The original `raw` object when no enrichment is needed or possible;
* otherwise a shallow copy of `raw` with `attachments` from the API response.
*/
export async function maybeRefetchSlackUnfurlAttachments(input: {
channelId: string | undefined;
threadTs: string | undefined;
messageTs: string | undefined;
originalRaw: unknown;
text: string | undefined;
}): Promise<unknown> {
const { channelId, threadTs, messageTs, originalRaw, text } = input;

// Skip: already has attachment data.
if (hasAttachments(originalRaw)) {
return originalRaw;
}

// Skip: no URLs means Slack won't generate unfurls.
if (!containsUrl(text)) {
return originalRaw;
}

// Skip: missing identifiers needed for the API call.
if (!channelId || !messageTs) {
return originalRaw;
}

const resolvedThreadTs = threadTs ?? messageTs;

// Retry with short delays — Slack generates most unfurls within 1–2 s.
const delaysMs = [400, 800, 1300];

for (const delayMs of delaysMs) {
await sleep(delayMs);

let replies: Awaited<ReturnType<typeof listThreadReplies>>;
try {
replies = await listThreadReplies({
channelId,
threadTs: resolvedThreadTs,
targetMessageTs: [messageTs],
limit: 1,
maxPages: 1,
});
} catch {
// Best-effort; a single failed attempt should not block the turn.
break;
}

const matched = replies.find((r) => r.ts === messageTs);
if (matched?.attachments?.length) {
const rendered = renderSlackLegacyAttachmentText(matched.attachments);
if (rendered) {
// Merge fetched attachments into the original raw shape so that
// appendSlackLegacyAttachmentText and other consumers work unchanged.
return {
...(originalRaw && typeof originalRaw === "object"
? originalRaw
: {}),
attachments: matched.attachments,
};
}
}
}

return originalRaw;
}
205 changes: 205 additions & 0 deletions packages/junior/tests/unit/slack/unfurl-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// ── Module mocks must be declared before importing the module under test ──────

vi.mock("@/chat/slack/channel", () => ({
listThreadReplies: vi.fn(),
}));

// Use fake timers so sleep() resolves instantly.
// Must be set up before the module under test resolves its timer calls.

import { listThreadReplies } from "@/chat/slack/channel";
import { maybeRefetchSlackUnfurlAttachments } from "@/chat/slack/unfurl-fetch";

const mockListThreadReplies = vi.mocked(listThreadReplies);

const CHANNEL = "C_TEST";
const THREAD_TS = "1700000000.000001";
const MESSAGE_TS = "1700000000.000001";

const UNFURL_ATTACHMENT = [
{
title: "Discord – Some Channel",
title_link: "https://discord.com/channels/123/456/789",
text: "sentry-self-hosted-generic-metrics-consumer-1 is unhealthy upon start",
footer: "Discord",
},
];

function baseInput(overrides?: {
channelId?: string | undefined;
threadTs?: string | undefined;
messageTs?: string | undefined;
originalRaw?: unknown;
text?: string | undefined;
}) {
return {
channelId: CHANNEL,
threadTs: THREAD_TS,
messageTs: MESSAGE_TS,
originalRaw: { channel: CHANNEL, ts: MESSAGE_TS, attachments: [] },
text: "check https://discord.com/channels/123/456/789",
...overrides,
};
}

describe("maybeRefetchSlackUnfurlAttachments", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
});

it("returns originalRaw unchanged when raw already has attachments", async () => {
const rawWithAttachments = {
channel: CHANNEL,
ts: MESSAGE_TS,
attachments: UNFURL_ATTACHMENT,
};

const promise = maybeRefetchSlackUnfurlAttachments(
baseInput({ originalRaw: rawWithAttachments }),
);
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toBe(rawWithAttachments);
expect(mockListThreadReplies).not.toHaveBeenCalled();
});

it("returns originalRaw unchanged when text has no URLs", async () => {
const promise = maybeRefetchSlackUnfurlAttachments(
baseInput({ text: "just a plain message, no links" }),
);
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toEqual(baseInput().originalRaw);
expect(mockListThreadReplies).not.toHaveBeenCalled();
});

it("returns originalRaw unchanged when channelId is undefined", async () => {
const promise = maybeRefetchSlackUnfurlAttachments(
baseInput({ channelId: undefined }),
);
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toEqual(baseInput().originalRaw);
expect(mockListThreadReplies).not.toHaveBeenCalled();
});

it("returns originalRaw unchanged when messageTs is undefined", async () => {
const promise = maybeRefetchSlackUnfurlAttachments(
baseInput({ messageTs: undefined }),
);
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toEqual(baseInput().originalRaw);
expect(mockListThreadReplies).not.toHaveBeenCalled();
});

it("returns enriched raw when Slack returns attachments on first retry", async () => {
mockListThreadReplies.mockResolvedValueOnce([
{ ts: MESSAGE_TS, attachments: UNFURL_ATTACHMENT },
]);

const promise = maybeRefetchSlackUnfurlAttachments(baseInput());
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toMatchObject({ attachments: UNFURL_ATTACHMENT });
expect(mockListThreadReplies).toHaveBeenCalledOnce();
expect(mockListThreadReplies).toHaveBeenCalledWith({
channelId: CHANNEL,
threadTs: THREAD_TS,
targetMessageTs: [MESSAGE_TS],
limit: 1,
maxPages: 1,
});
});

it("retries when first call returns no attachments and second succeeds", async () => {
mockListThreadReplies
.mockResolvedValueOnce([{ ts: MESSAGE_TS, attachments: [] }])
.mockResolvedValueOnce([
{ ts: MESSAGE_TS, attachments: UNFURL_ATTACHMENT },
]);

const promise = maybeRefetchSlackUnfurlAttachments(baseInput());
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toMatchObject({ attachments: UNFURL_ATTACHMENT });
expect(mockListThreadReplies).toHaveBeenCalledTimes(2);
});

it("returns originalRaw gracefully when all retries find no attachments", async () => {
mockListThreadReplies.mockResolvedValue([
{ ts: MESSAGE_TS, attachments: [] },
]);

const promise = maybeRefetchSlackUnfurlAttachments(baseInput());
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toEqual(baseInput().originalRaw);
expect(mockListThreadReplies).toHaveBeenCalledTimes(3);
});

it("returns originalRaw gracefully when listThreadReplies throws", async () => {
mockListThreadReplies.mockRejectedValueOnce(new Error("network error"));

const promise = maybeRefetchSlackUnfurlAttachments(baseInput());
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toEqual(baseInput().originalRaw);
});

it("uses messageTs as threadTs fallback when threadTs is undefined", async () => {
mockListThreadReplies.mockResolvedValueOnce([
{ ts: MESSAGE_TS, attachments: UNFURL_ATTACHMENT },
]);

const promise = maybeRefetchSlackUnfurlAttachments(
baseInput({ threadTs: undefined }),
);
await vi.runAllTimersAsync();
await promise;

expect(mockListThreadReplies).toHaveBeenCalledWith(
expect.objectContaining({ threadTs: MESSAGE_TS }),
);
});

it("preserves existing raw fields on the returned enriched object", async () => {
const rawWithMeta = {
channel: CHANNEL,
ts: MESSAGE_TS,
user: "U_SERGIY",
attachments: [],
};
mockListThreadReplies.mockResolvedValueOnce([
{ ts: MESSAGE_TS, attachments: UNFURL_ATTACHMENT },
]);

const promise = maybeRefetchSlackUnfurlAttachments(
baseInput({ originalRaw: rawWithMeta }),
);
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toMatchObject({
channel: CHANNEL,
ts: MESSAGE_TS,
user: "U_SERGIY",
attachments: UNFURL_ATTACHMENT,
});
});
});
Loading