diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index b2fa36d36..b9a15c6d6 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -30,6 +30,7 @@ import { isRetryableSessionApiError, openSessionEventStream, serializeAttachment, + serializeMessageLinks, serializeMessage, sessionStreamError } from './session-api' @@ -2079,6 +2080,7 @@ async function slackApiMessageFromSlack( displayTextSource: displayText.source, id, isMention: id === currentMessage.id ? currentMessage.isMention === true : false, + links: serializeMessageLinks(undefined, message), raw: message, rawSlackAttachmentCount: displayText.rawAttachmentCount, rawSlackBlockCount: displayText.rawBlockCount, diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index 1cd102fff..4b064825d 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -1,11 +1,12 @@ import type { RustSessionStreamEvent } from '@centaur/harness-events' -import type { Attachment, Message } from 'chat' +import type { Attachment, LinkPreview, Message } from 'chat' import { renderSlackDisplayText, slackMessagePromptText } from './slack-display-text' import type { ForwardSessionInput, JsonObject, JsonValue, SlackbotV2ApiAttachment, + SlackbotV2ApiMessageLink, SlackbotV2ApiMessage, SlackbotV2AppendMessagesRequest, SlackbotV2CreateSessionRequest, @@ -214,6 +215,7 @@ export async function serializeMessage(message: Message): Promise() + const add = (value: unknown): void => { + if (!isJsonObject(value) || seen.has(value)) return + records.push(value) + seen.add(value) + } + + add(raw) + if (isJsonObject(raw)) { + add(raw.event) + add(raw.message) + if (isJsonObject(raw.event)) add(raw.event.message) + } + return records +} + +function extractRawSlackBlockLinks( + value: JsonValue | undefined, + links: SlackbotV2ApiMessageLink[] +): void { + if (!Array.isArray(value)) return + for (const block of value) extractRawSlackElementLinks(block, links) +} + +function extractRawSlackElementLinks( + value: JsonValue | undefined, + links: SlackbotV2ApiMessageLink[] +): void { + if (!isJsonObject(value)) return + if (value.type === 'link') { + const url = stringValue(value.url) + if (url) links.push({ isSlackMessage: isSlackMessageUrl(url), url }) + } + for (const key of ['elements', 'fields']) { + const children = value[key] + if (Array.isArray(children)) { + for (const child of children) extractRawSlackElementLinks(child, links) + } + } + extractRawSlackElementLinks(value.text, links) + extractRawSlackElementLinks(value.accessory, links) +} + +function extractRawSlackTextLinks( + value: JsonValue | undefined, + links: SlackbotV2ApiMessageLink[] +): void { + const text = stringValue(value) + if (!text) return + for (const match of text.matchAll(/<([a-z]+:\/\/[^>|]+)(?:\|[^>]+)?>/gi)) { + const url = match[1] + if (url) links.push({ isSlackMessage: isSlackMessageUrl(url), url }) + } +} + +function extractRawSlackAttachmentLinks( + value: JsonValue | undefined, + links: SlackbotV2ApiMessageLink[] +): void { + if (!Array.isArray(value)) return + for (const attachment of value) { + if (!isJsonObject(attachment)) continue + const url = stringValue(attachment.from_url) ?? stringValue(attachment.original_url) + if (!url) continue + links.push({ + description: stringValue(attachment.text), + isSlackMessage: isSlackMessageUrl(url), + siteName: stringValue(attachment.service_name), + title: stringValue(attachment.title), + url + }) + } +} + +function normalizeApiLinks( + links: SlackbotV2ApiMessageLink[] +): SlackbotV2ApiMessageLink[] | undefined { + const seen = new Set() + const normalized: SlackbotV2ApiMessageLink[] = [] + for (const link of links) { + const url = link.url.trim() + if (!url || seen.has(url)) continue + seen.add(url) + normalized.push({ ...link, url }) + } + return normalized.length > 0 ? normalized : undefined +} + +function isSlackMessageUrl(url: string): boolean { + return SLACK_MESSAGE_URL_PATTERN.test(url) +} + function slackTeamId(raw: unknown): string | undefined { if (!isJsonObject(raw)) return undefined const team = raw.team @@ -1131,6 +1260,7 @@ function sessionSlackTextMetadata(message: SlackbotV2ApiMessage): JsonObject { if (typeof message.rawSlackAttachmentCount === 'number') { fields.slack_raw_attachment_count = message.rawSlackAttachmentCount } + if (message.links?.length) fields.slack_link_count = message.links.length return fields } diff --git a/services/slackbotv2/src/slack-display-text.ts b/services/slackbotv2/src/slack-display-text.ts index 28f49e433..51e37df08 100644 --- a/services/slackbotv2/src/slack-display-text.ts +++ b/services/slackbotv2/src/slack-display-text.ts @@ -56,13 +56,68 @@ export function renderSlackDisplayText(input: { raw: unknown; text: string }): S export function slackMessagePromptText(message: { displayText?: string displayTextSource?: SlackDisplayTextSource + links?: PromptLink[] text: string }): string { const source = message.displayTextSource - if ((source === 'raw_blocks' || source === 'raw_attachments') && message.displayText) { - return message.displayText + const text = + (source === 'raw_blocks' || source === 'raw_attachments') && message.displayText + ? message.displayText + : message.text + const links = promptLinksText(message.links, text) + return [text, links].filter(part => part.trim()).join('\n\n') +} + +type PromptLink = { + description?: string + isSlackMessage?: boolean + siteName?: string + title?: string + url: string +} + +function promptLinksText( + links: readonly PromptLink[] | undefined, + existingText: string +): string { + const normalized = normalizePromptLinks(links).filter(link => !existingText.includes(link.url)) + if (normalized.length === 0) return '' + + const hasSlackMessageLink = normalized.some(link => link.isSlackMessage) + const lines = ['Links included in the Slack message:'] + if (hasSlackMessageLink) { + lines.push( + 'If the request is context-dependent, inspect linked Slack message/thread links before responding.' + ) + } + for (const link of normalized) { + lines.push(`- ${promptLinkLine(link)}`) } - return message.text + return lines.join('\n') +} + +function normalizePromptLinks( + links: readonly PromptLink[] | undefined +): PromptLink[] { + const seen = new Set() + const normalized: PromptLink[] = [] + for (const link of links ?? []) { + const url = link.url.trim() + if (!url || seen.has(url)) continue + seen.add(url) + normalized.push({ ...link, url }) + } + return normalized +} + +function promptLinkLine(link: PromptLink): string { + const fields = [ + link.isSlackMessage ? `Slack message/thread: ${link.url}` : link.url, + link.title ? `Title: ${link.title}` : undefined, + link.description ? `Description: ${link.description}` : undefined, + link.siteName ? `Site: ${link.siteName}` : undefined + ].filter(Boolean) + return fields.join(' | ') } function slackMessageRecords(raw: unknown): UnknownRecord[] { diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index da1a6b573..ddb1458a5 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -30,6 +30,15 @@ export type SlackbotV2ApiAttachment = { width?: number } +export type SlackbotV2ApiMessageLink = { + description?: string + imageUrl?: string + isSlackMessage?: boolean + siteName?: string + title?: string + url: string +} + export type SlackbotV2ApiMessage = { attachments: SlackbotV2ApiAttachment[] author: SlackbotV2ApiAuthor @@ -37,6 +46,7 @@ export type SlackbotV2ApiMessage = { displayTextSource?: SlackDisplayTextSource id: string isMention: boolean + links?: SlackbotV2ApiMessageLink[] raw: unknown rawSlackAttachmentCount?: number rawSlackBlockCount?: number diff --git a/services/slackbotv2/test/session-api.test.ts b/services/slackbotv2/test/session-api.test.ts index 8932d7ed7..e7ace403a 100644 --- a/services/slackbotv2/test/session-api.test.ts +++ b/services/slackbotv2/test/session-api.test.ts @@ -262,6 +262,78 @@ describe('Slack display text fallback', () => { ) expect(lineContent(line).at(-1)).toEqual({ type: 'text', text: expected }) }) + + test('forwards hidden Slack message links with non-empty adapter text', async () => { + const { fetchFn, requests } = fakeApi() + const slackUrl = 'https://acme.slack.com/archives/C1234567890/p1700000000000100' + const serialized = await serializeMessage({ + attachments: [], + author: { + fullName: 'Test User', + isBot: false, + isMe: false, + userId: 'U1', + userName: 'test' + }, + id: '1700000000.000100', + isMention: true, + links: [], + metadata: { dateSent: new Date('2026-06-10T00:00:00.000Z') }, + raw: { + blocks: [ + { + elements: [ + { + elements: [ + { text: 'continue', type: 'text' }, + { text: 'source thread', type: 'link', url: slackUrl } + ], + type: 'rich_text_section' + } + ], + type: 'rich_text' + } + ], + team_id: 'T1' + }, + text: 'continue', + threadId: 'slack:C1:1700000000.000100' + } as unknown as Parameters[0]) + + await forwardToSessionApi(options(fetchFn), forwardInput(serialized)) + + expect(serialized.links).toEqual([{ isSlackMessage: true, url: slackUrl }]) + const expected = [ + 'continue', + '', + 'Links included in the Slack message:', + 'If the request is context-dependent, inspect linked Slack message/thread links before responding.', + `- Slack message/thread: ${slackUrl}` + ].join('\n') + expect(appendedTextParts(requests)).toContain(expected) + + const line = executeLine(requests) + expect(line.trace_metadata).toEqual( + expect.objectContaining({ + slack_link_count: 1, + slack_text_source: 'text' + }) + ) + expect(lineContent(line).at(-1)).toEqual({ type: 'text', text: expected }) + }) + + test('does not duplicate links already visible in Slack text', async () => { + const { fetchFn, requests } = fakeApi() + const slackUrl = 'https://acme.slack.com/archives/C1234567890/p1700000000000100' + const message = apiMessage(`continue (${slackUrl})`, { + links: [{ isSlackMessage: true, url: slackUrl }] + }) + + await forwardToSessionApi(options(fetchFn), forwardInput(message)) + + expect(appendedTextParts(requests)).toContain(`continue (${slackUrl})`) + expect(appendedTextParts(requests).join('\n')).not.toContain('Links included in the Slack message') + }) }) describe('forwardToSessionApi overrides', () => {