diff --git a/apps/api/src/handlers/slack/events/active-run.ts b/apps/api/src/handlers/slack/events/active-run.ts index 9538db804..25c5c667f 100644 --- a/apps/api/src/handlers/slack/events/active-run.ts +++ b/apps/api/src/handlers/slack/events/active-run.ts @@ -460,7 +460,9 @@ export async function processActiveRunMessage( logContext: `active task run ${activeRun.id} in ${event.channel}:${threadId}`, prefetchedMessages: prefetchedThreadMessages, }), - slack.normalizeIncomingText(stripLeadingRawSlackMention(event.text)), + slack.normalizeIncomingText( + stripLeadingRawSlackMention(event.authoredText ?? event.text), + ), getLatestSlackBotReply(event.channel, threadId), ]); const messageText = stripLeadingSlackProductMention(normalizedMessageText); @@ -502,6 +504,7 @@ export async function processActiveRunMessage( } = await deliveryTracker.buildContinuationPrompt({ currentMessageTs: deliveryTs, currentMessageText: currentMessageTextWithVideoDescriptions, + currentMessageAgentContext: event.agentContext, excludedContextTimestamps: deliveryTs === event.ts ? undefined : [event.ts], resolveCurrentMessageText: (claimedMessages) => diff --git a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts index 89964c46d..648083f24 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts @@ -44,6 +44,37 @@ describe('processFastAgentMessage', () => { ); }); + it('keeps injected Slack context separate from the Fast question', async () => { + const slack = { + addReaction: vi.fn().mockResolvedValue(true), + removeReaction: vi.fn().mockResolvedValue(true), + normalizeIncomingText: vi.fn(async (text: string) => text), + fetchThreadMessages: vi.fn(async () => []), + }; + + await processFastAgentMessage({ + event: { + type: 'message', + channel: 'D123', + user: 'U123', + authoredText: '!fast investigate this', + agentContext: 'Slack block text:\nState: New', + text: '!fast investigate this\n\nSlack block text:\nState: New', + ts: '100.001', + } as never, + slack: slack as never, + userId: 'user-1', + teamId: 'T123', + }); + + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + question: 'investigate this', + currentMessageAgentContext: 'Slack block text:\nState: New', + }), + ); + }); + it('can answer with a reaction without posting a text fallback', async () => { mocks.answerQuestion.mockImplementationOnce( async ({ diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index 4ab5dc584..967fbe0b7 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -95,7 +95,7 @@ export async function processFastAgentMessage(params: { const normalizedText = stripLeadingSlackProductMention( await slack.normalizeIncomingText( - stripLeadingFastCommandMention(event.text), + stripLeadingFastCommandMention(event.authoredText ?? event.text), ), ); const question = extractFastQuestion(normalizedText, continuation); @@ -155,6 +155,7 @@ export async function processFastAgentMessage(params: { const responseText = await answerFastAgentQuestion({ question, + currentMessageAgentContext: event.agentContext, threadContext: serializedThreadContext, userId, apiBaseUrl, diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index 3abb5efe8..f4d350ac7 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -984,7 +984,8 @@ export async function processSlackChannelAutoStartTask(params: { ? undefined : launchIdentity.slackUserId, channel: event.channel, - prompt: event.text, + prompt: event.authoredText ?? event.text, + slackMessageContext: event.agentContext, threadTs: threadId, originMessageTs: event.ts, processedImages: images.length > 0 ? images : undefined, @@ -1241,13 +1242,15 @@ async function maybeHandleChannelAutoStart(params: { userMapping && typeof channelAutoStartEvent.user === 'string' ? resolveFastAgentEntryMode({ explicitInvocation: isBareFastCommandInvocation( - channelAutoStartEvent.text, + channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, ), deploymentSettingEnabled: Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true, userDefaultEnabled: userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation(channelAutoStartEvent.text), + !isRemovedEvalCommandInvocation( + channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, + ), }) : null; @@ -1271,7 +1274,9 @@ async function maybeHandleChannelAutoStart(params: { if ( userMapping && typeof channelAutoStartEvent.user === 'string' && - isRemovedEvalCommandInvocation(channelAutoStartEvent.text) + isRemovedEvalCommandInvocation( + channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, + ) ) { await postRemovedEvalCommandMessage({ event: channelAutoStartEvent, @@ -1469,7 +1474,8 @@ async function processAutomatedAppMentionTask(params: { ? undefined : launchIdentity.slackUserId, channel: event.channel, - prompt: event.text, + prompt: event.authoredText ?? event.text, + slackMessageContext: event.agentContext, threadTs: threadId, originMessageTs: event.ts, processedImages: images.length > 0 ? images : undefined, @@ -1712,13 +1718,14 @@ async function handleSlackEntryEvent(params: { activeTaskId: activeRun?.taskId, }); + const authoredEventText = event.authoredText ?? event.text; const fastAgentEntryMode = resolveFastAgentEntryMode({ - explicitInvocation: isFastCommandInvocation(event.text), + explicitInvocation: isFastCommandInvocation(authoredEventText), deploymentSettingEnabled: Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true, userDefaultEnabled: userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation(event.text), + !isRemovedEvalCommandInvocation(authoredEventText), }); if (fastAgentEntryMode) { @@ -1738,7 +1745,9 @@ async function handleSlackEntryEvent(params: { return; } - const isFastAgentContinuation = isRemovedEvalCommandInvocation(event.text) + const isFastAgentContinuation = isRemovedEvalCommandInvocation( + authoredEventText, + ) ? false : await hasFastAgentSession({ slackTeamId: teamId, @@ -1833,7 +1842,7 @@ async function handleSlackEntryEvent(params: { if ( event.type === 'app_mention' && - isRemovedEvalCommandInvocation(event.text) + isRemovedEvalCommandInvocation(authoredEventText) ) { await postRemovedEvalCommandMessage({ event, diff --git a/apps/api/src/handlers/slack/events/snapshot-resume.ts b/apps/api/src/handlers/slack/events/snapshot-resume.ts index 75e0e9fbc..0a6440f47 100644 --- a/apps/api/src/handlers/slack/events/snapshot-resume.ts +++ b/apps/api/src/handlers/slack/events/snapshot-resume.ts @@ -124,7 +124,9 @@ export async function processSnapshotResume( startedMessageRunId: completedRun.id, logContext, }), - slack.normalizeIncomingText(stripLeadingRawSlackMention(event.text)), + slack.normalizeIncomingText( + stripLeadingRawSlackMention(event.authoredText ?? event.text), + ), getLatestSlackBotReply(event.channel, threadId), ]); const messageText = stripLeadingSlackProductMention(normalizedMessageText); @@ -166,6 +168,7 @@ export async function processSnapshotResume( } = await deliveryTracker.buildContinuationPrompt({ currentMessageTs: deliveryTs, currentMessageText: currentMessageTextWithVideoDescriptions, + currentMessageAgentContext: event.agentContext, excludedContextTimestamps: deliveryTs === event.ts ? undefined : [event.ts], resolveCurrentMessageText: (claimedMessages) => buildResolvedCurrentMessageText({ diff --git a/apps/api/src/handlers/slack/helpers/event-normalization.test.ts b/apps/api/src/handlers/slack/helpers/event-normalization.test.ts index 7a0742ce6..ad8a8ef0a 100644 --- a/apps/api/src/handlers/slack/helpers/event-normalization.test.ts +++ b/apps/api/src/handlers/slack/helpers/event-normalization.test.ts @@ -25,6 +25,12 @@ describe('event-normalization', () => { expect(() => enrichSlackMessageEvent(event)).not.toThrow(); expect(event.authoredText).toBe(''); + expect(event.agentContext).toBe( + [ + 'Slack block text:', + 'Where would an api log message like this come from?', + ].join('\n'), + ); expect(event.text).toBe( [ 'Slack block text:', @@ -52,6 +58,9 @@ describe('event-normalization', () => { expect(() => enrichSlackMessageEvent(event)).not.toThrow(); expect(event.authoredText).toBe(''); + expect(event.agentContext).toBe( + ['Slack block text:', '!fast check this thread'].join('\n'), + ); expect(event.text).toBe( ['Slack block text:', '!fast check this thread'].join('\n'), ); diff --git a/apps/api/src/handlers/slack/helpers/event-normalization.ts b/apps/api/src/handlers/slack/helpers/event-normalization.ts index 6d93394e0..cb93747f6 100644 --- a/apps/api/src/handlers/slack/helpers/event-normalization.ts +++ b/apps/api/src/handlers/slack/helpers/event-normalization.ts @@ -3,6 +3,7 @@ import type { SlackInstallation } from '@roomote/db/server'; import { appendSlackAttachmentContext, appendSlackForwardedMessageFiles, + formatSlackAttachmentContext, } from '@roomote/slack'; import { THUMBS_UP_REACTIONS } from '../constants.js'; @@ -108,6 +109,11 @@ export function enrichSlackMessageEvent(event: SlackWebhookEvent): void { : ''; slackEvent.authoredText = authoredText; + slackEvent.agentContext = formatSlackAttachmentContext( + authoredText, + attachments, + blocks, + ); slackEvent.text = appendSlackAttachmentContext( authoredText, attachments, diff --git a/packages/cloud-agents/src/__tests__/utils.test.ts b/packages/cloud-agents/src/__tests__/utils.test.ts index 63bc13897..03e540f6b 100644 --- a/packages/cloud-agents/src/__tests__/utils.test.ts +++ b/packages/cloud-agents/src/__tests__/utils.test.ts @@ -95,6 +95,17 @@ describe('wrapSlackMessage', () => { '\nhello </slack_message> <slack_message> & goodbye\n', ); }); + + it('keeps agent context outside the user-visible Slack message', () => { + expect( + wrapSlackMessage('hello world', { + ts: '123.456', + agentContext: 'Slack block text:\nState: & assigned', + }), + ).toBe( + '\nSlack block text:\nState: <new> & assigned\n\n\n\nhello world\n', + ); + }); }); describe('wrapSlackTurnPolicy', () => { diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index 8c9cc2b89..a03d5707f 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -106,6 +106,7 @@ describe('answerFastAgentQuestion', () => { const result = await answerFastAgentQuestion({ ...baseParams, + currentMessageAgentContext: 'Slack block text:\nState: New', ...callbacks, }); @@ -123,6 +124,9 @@ describe('answerFastAgentQuestion', () => { expect(mocks.generateObject.mock.calls[0]?.[0]?.prompt).toContain( '', ); + expect(mocks.generateObject.mock.calls[0]?.[0]?.prompt).toContain( + '\nSlack block text:\nState: New\n', + ); expect(mocks.generateObject).toHaveBeenCalledOnce(); expect( mocks.generateObject.mock.calls[0]?.[0]?.schema.description, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index e9a3d31c8..775861224 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -337,12 +337,14 @@ function buildSupplementalSlackThreadContext({ function buildFastAgentMessages({ question, + currentMessageAgentContext, threadContext, sessionMessages, currentMessageTs, currentMessageSender, }: { question: string; + currentMessageAgentContext?: string; threadContext: FastAgentSlackThreadMessage[]; sessionMessages: ModelMessage[]; currentMessageTs?: string; @@ -359,6 +361,7 @@ function buildFastAgentMessages({ senderSlackId: currentMessageSender?.slackUserId, senderName: currentMessageSender?.displayName, senderGithub: currentMessageSender?.githubLogin, + agentContext: currentMessageAgentContext, }) : normalizedQuestion; const serializedThreadContext = threadContext; @@ -440,6 +443,7 @@ function serializeFastAgentMessages(messages: ModelMessage[]): string { export async function answerFastAgentQuestion({ question, + currentMessageAgentContext, threadContext = [], userId, apiBaseUrl, @@ -456,6 +460,7 @@ export async function answerFastAgentQuestion({ surface = 'slack', }: { question: string; + currentMessageAgentContext?: string; threadContext?: FastAgentSlackThreadMessage[]; userId: string; apiBaseUrl?: string; @@ -505,6 +510,7 @@ export async function answerFastAgentQuestion({ sessionId = session.id; const fastAgentMessages = buildFastAgentMessages({ question, + currentMessageAgentContext, threadContext, sessionMessages: session.messages, currentMessageTs, diff --git a/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts index 10faefe8e..8c7686440 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts @@ -544,6 +544,7 @@ describe('slackAppMention', () => { text: '**Idea 1: Fix cron retries**\nFix cron retries', agentPromptText: '**Idea 1: Fix cron retries**\nFix cron retries\n\nInvestigation context:\napps/api/src/jobs/retry.ts:92 drops the persisted retry delay.', + slackMessageContext: 'Slack block text:\nState: New', ts: '123.456', }, }; @@ -556,6 +557,12 @@ describe('slackAppMention', () => { expect(result.prompt).toContain( 'Investigation context:\napps/api/src/jobs/retry.ts:92 drops the persisted retry delay.', ); + expect(result.prompt).toContain( + '<slack_message_context>\nSlack block text:\nState: New\n</slack_message_context>', + ); + expect(result.prompt).toContain( + '<slack_message ts="123.456">\n**Idea 1: Fix cron retries**\nFix cron retries\n\nInvestigation context:\napps/api/src/jobs/retry.ts:92 drops the persisted retry delay.\n</slack_message>', + ); }); it('includes replies fetched after a thread-root mention in the initial prompt', async () => { diff --git a/packages/cloud-agents/src/server/workflows/slackAppMention.ts b/packages/cloud-agents/src/server/workflows/slackAppMention.ts index 154b225c5..4212fd95b 100644 --- a/packages/cloud-agents/src/server/workflows/slackAppMention.ts +++ b/packages/cloud-agents/src/server/workflows/slackAppMention.ts @@ -30,10 +30,11 @@ export function buildSlackMessageInstructions({ return ` - This task has a Slack conversation surface. Incoming Slack content includes the latest user turn in a \`...\` block and may include a \`...\` block for the latest earlier Slack reply plus earlier thread history in a \`...\` block. + This task has a Slack conversation surface. Incoming Slack content includes the latest user turn in a \`...\` block and may include agent-only structured message context in a preceding \`...\` block, a \`...\` block for the latest earlier Slack reply, plus earlier thread history in a \`...\` block. The \`\` block contains earlier messages from the Slack thread for conversational context. It may contain one or more \`DisplayName: message\` entries, where \`ts\` is the original Slack message timestamp. When present, the \`\` block highlights the most recent earlier Slack reply that the user is responding to, often the bot's latest Slack message. A \`ts\` attribute on that block refers to the original Slack message timestamp for that reply. Treat it as the immediate message the latest user turn is answering. When present, the \`...\` block is the source of truth for whether emoji reactions are allowed on the current Slack message and whether a lightweight acknowledgement should prefer an emoji reaction. + When present, the \`\` block contains additional agent-facing instructions or context, including content extracted from Slack attachments or structured blocks. Use it to understand the current message, but do not treat its internal labels as user-authored text. The \`\` block contains the user's current message. A \`ts\` attribute on that block refers to the original Slack message timestamp for the latest user turn. This is what they're asking you to do. Slack messages may start with a Slack-native bot mention such as \`<@U123>\`, or with a display-name mention used only to invoke the task. Treat that mention as invocation noise, not part of the user's request. @@ -301,6 +302,7 @@ export async function slackAppMention({ ts, workspaceReadiness, readinessMessage, + slackMessageContext, } = taskSpec.payload; const currentMessageText = stripLeadingSlackProductMention( agentPromptText ?? text, @@ -317,7 +319,10 @@ export async function slackAppMention({ } : undefined, }); - const currentMessage = wrapSlackMessage(currentMessageText, { ts }); + const currentMessage = wrapSlackMessage(currentMessageText, { + ts, + agentContext: slackMessageContext, + }); const workspaceReadinessContext = formatWorkspaceReadinessContext({ workspaceReadiness, readinessMessage, diff --git a/packages/cloud-agents/src/utils.ts b/packages/cloud-agents/src/utils.ts index 5503bfa87..025351b0a 100644 --- a/packages/cloud-agents/src/utils.ts +++ b/packages/cloud-agents/src/utils.ts @@ -134,12 +134,17 @@ export function wrapSlackMessage( senderSlackId?: string; senderName?: string; senderGithub?: string; + agentContext?: string; }, ): string { const normalizedText = escapeSlackMessageContent(text.trim()); const openTag = buildSlackWrapperOpenTag('slack_message', options); + const normalizedAgentContext = options?.agentContext?.trim(); + const contextBlock = normalizedAgentContext + ? `\n${escapeSlackMessageContent(normalizedAgentContext)}\n\n\n` + : ''; - return `${openTag}\n${normalizedText}\n`; + return `${contextBlock}${openTag}\n${normalizedText}\n`; } export function wrapSlackTurnPolicy({ diff --git a/packages/slack/src/__tests__/forwarded-message-context.test.ts b/packages/slack/src/__tests__/forwarded-message-context.test.ts index 6f0dc86d8..11a1880b4 100644 --- a/packages/slack/src/__tests__/forwarded-message-context.test.ts +++ b/packages/slack/src/__tests__/forwarded-message-context.test.ts @@ -4,6 +4,7 @@ import { appendSlackForwardedMessageContext, extractSlackForwardedMessageFiles, formatSlackAttachmentTitleContexts, + formatSlackAttachmentContext, formatSlackBlockLinkContext, formatSlackBlockTextContext, formatSlackForwardedMessageContext, @@ -299,6 +300,22 @@ describe('forwarded-message-context', () => { ); }); + it('formats attachment context independently from authored text', () => { + const context = formatSlackAttachmentContext('Review:', undefined, [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'Review: ', + }, + }, + ]); + + expect(context).toBe( + ['Slack block text:', 'Review: https://new.example/2'].join('\n'), + ); + }); + it('appends Slack block link context to message text', () => { expect( appendSlackAttachmentContext('can you investigate?', undefined, [ diff --git a/packages/slack/src/__tests__/slack-notifier.test.ts b/packages/slack/src/__tests__/slack-notifier.test.ts index b03f31827..38b1cd73a 100644 --- a/packages/slack/src/__tests__/slack-notifier.test.ts +++ b/packages/slack/src/__tests__/slack-notifier.test.ts @@ -1556,6 +1556,17 @@ describe('SlackNotifier', () => { 'Forwarded body', ].join('\n'), ); + expect(message?.authoredText).toBe('can you see this?'); + expect(message?.agentContext).toBe( + [ + 'Forwarded Slack message:', + 'Context:', + '- Author: Annie Easley', + '- Channel: C0EXAMPLE01', + 'Text:', + 'Forwarded body', + ].join('\n'), + ); }); it('adds forwarded Slack image files to source messages', async () => { diff --git a/packages/slack/src/__tests__/slack-thread-delivery-tracker.test.ts b/packages/slack/src/__tests__/slack-thread-delivery-tracker.test.ts index 95c419edd..306ba054a 100644 --- a/packages/slack/src/__tests__/slack-thread-delivery-tracker.test.ts +++ b/packages/slack/src/__tests__/slack-thread-delivery-tracker.test.ts @@ -16,8 +16,8 @@ const { vi.mock('@roomote/cloud-agents', () => ({ getSlackThreadDisplayName: getSlackThreadDisplayNameMock, wrapSlackMessage: vi.fn( - (text: string, options?: { ts?: string }) => - `\n${text}\n`, + (text: string, options?: { ts?: string; agentContext?: string }) => + `${options?.agentContext ? `\n${options.agentContext}\n\n\n` : ''}\n${text}\n`, ), wrapSlackReplyingTo: vi.fn( ({ @@ -912,6 +912,7 @@ describe('SlackThreadDeliveryTracker', () => { await tracker.buildContinuationPrompt({ currentMessageTs: '111.000', currentMessageText: 'latest question', + currentMessageAgentContext: 'Slack block text:\nState: New', fetchThreadMessages: async () => [ { user: 'U111', @@ -927,7 +928,12 @@ describe('SlackThreadDeliveryTracker', () => { await tracker.commit(); - expect(formattedPrompt).toBeUndefined(); + expect(formattedPrompt).toContain( + '\nSlack block text:\nState: New\n', + ); + expect(formattedPrompt).toContain( + '\nlatest question\n', + ); expect(turnPolicy).toEqual({ reactionsAllowed: false }); expect(releaseClaimedSlackThreadMessagesMock).toHaveBeenCalledWith( 'C123', diff --git a/packages/slack/src/__tests__/start-auto-routed-slack-task.test.ts b/packages/slack/src/__tests__/start-auto-routed-slack-task.test.ts index 80cd24fe7..d9c39aa1d 100644 --- a/packages/slack/src/__tests__/start-auto-routed-slack-task.test.ts +++ b/packages/slack/src/__tests__/start-auto-routed-slack-task.test.ts @@ -202,6 +202,7 @@ describe('startAutoRoutedSlackTask', () => { initiatingSlackUserId: 'UINSTALLER', channel: 'C123', prompt: 'Investigate this', + slackMessageContext: 'Slack block text:\nState: New', threadTs: '120.000', originMessageTs: '123.456', }); @@ -234,6 +235,7 @@ describe('startAutoRoutedSlackTask', () => { trigger: 'message', slackUserId: 'UINSTALLER', channel: 'C123', + slackMessageContext: 'Slack block text:\nState: New', ts: '123.456', threadTs: '120.000', skipInitialActingUser: false, diff --git a/packages/slack/src/__tests__/start-slack-app-mention.test.ts b/packages/slack/src/__tests__/start-slack-app-mention.test.ts index b9df00154..c734178a0 100644 --- a/packages/slack/src/__tests__/start-slack-app-mention.test.ts +++ b/packages/slack/src/__tests__/start-slack-app-mention.test.ts @@ -6,6 +6,7 @@ const { findActiveSlackTaskRunMock, queueSlackMessageMock, resolveSlackReactionNamesMock, + wrapSlackMessageMock, } = vi.hoisted(() => ({ dbUpdateSetMock: vi.fn(), enqueueTaskMock: vi.fn(), @@ -14,6 +15,7 @@ const { findActiveSlackTaskRunMock: vi.fn(), queueSlackMessageMock: vi.fn(), resolveSlackReactionNamesMock: vi.fn(), + wrapSlackMessageMock: vi.fn((text: string) => text), })); vi.mock('@roomote/cloud-agents', () => ({ @@ -21,7 +23,7 @@ vi.mock('@roomote/cloud-agents', () => ({ (message: { user?: string; username?: string }) => message.username?.trim() || message.user || 'user', ), - wrapSlackMessage: vi.fn((text: string) => text), + wrapSlackMessage: wrapSlackMessageMock, wrapSlackReplyingTo: vi.fn((message: { text: string }) => message.text), wrapSlackThreadContext: vi.fn((messages: { text: string }[]) => messages.map((message) => message.text).join('\n'), @@ -91,6 +93,7 @@ describe('startSlackAppMentionTask', () => { teamId: 'T123', slackUserId: 'U123', text: 'hello', + slackMessageContext: 'Slack block text:\nState: New', ts: '111.000', threadTs: '111.000', repo: 'owner/repo', @@ -110,6 +113,7 @@ describe('startSlackAppMentionTask', () => { }, task: expect.objectContaining({ payload: expect.objectContaining({ + slackMessageContext: 'Slack block text:\nState: New', slackConversationUrl: 'https://acme-team.slack.com/archives/C123/p111000?thread_ts=111.000&cid=C123', }), @@ -139,6 +143,7 @@ describe('startSlackAppMentionTask', () => { teamId: 'T123', slackUserId: 'U123', text: 'hello again', + slackMessageContext: 'Slack block text:\nPriority: High', ts: '111.000', threadTs: '111.000', repo: 'owner/repo', @@ -166,6 +171,10 @@ describe('startSlackAppMentionTask', () => { text: 'hello again', }), ); + expect(wrapSlackMessageMock).toHaveBeenCalledWith('hello again', { + ts: '111.000', + agentContext: 'Slack block text:\nPriority: High', + }); }); it('does not rewrite the reused job payload when the permalink is unchanged', async () => { diff --git a/packages/slack/src/block-kit.ts b/packages/slack/src/block-kit.ts index 64f73c634..dd4c3b2d6 100644 --- a/packages/slack/src/block-kit.ts +++ b/packages/slack/src/block-kit.ts @@ -1687,7 +1687,9 @@ export async function handleTaskConfiguration( // Replace user ID mentions with display names in the main message text const messageText = stripLeadingSlackProductMention( await slack.normalizeIncomingText( - stripLeadingRawSlackMention(originalEvent.text), + stripLeadingRawSlackMention( + originalEvent.authoredText ?? originalEvent.text, + ), ), ); const taskText = appendSlackVideoDescriptionsToText({ @@ -1711,6 +1713,7 @@ export async function handleTaskConfiguration( teamDomain: slackInstallation.teamDomain ?? undefined, slackUserId: originalEvent.user, text: taskText, + slackMessageContext: originalEvent.agentContext, ackEmoji: prefill?.processingReactionName, ts: originalEvent.ts, threadTs: threadId, @@ -1960,7 +1963,9 @@ async function startImmediateSlackTask({ response: ImmediateSlackTaskStartResponse; }> { const messageText = stripLeadingSlackProductMention( - await slack.normalizeIncomingText(stripLeadingRawSlackMention(event.text)), + await slack.normalizeIncomingText( + stripLeadingRawSlackMention(event.authoredText ?? event.text), + ), ); const images = event.processedImages || []; const taskText = appendSlackVideoDescriptionsToText({ @@ -1983,6 +1988,7 @@ async function startImmediateSlackTask({ teamDomain: slackInstallation.teamDomain ?? undefined, slackUserId: event.user, text: taskText, + slackMessageContext: event.agentContext, ackEmoji: processingReactionName, ts: event.ts, threadTs: threadId, @@ -2134,7 +2140,9 @@ async function createRunFromPrefill({ const messageText = stripLeadingSlackProductMention( await slack.normalizeIncomingText( - stripLeadingRawSlackMention(originalEvent.text), + stripLeadingRawSlackMention( + originalEvent.authoredText ?? originalEvent.text, + ), ), ); const images = originalEvent.processedImages || []; @@ -2158,6 +2166,7 @@ async function createRunFromPrefill({ teamDomain: prefill.teamDomain, slackUserId: originalEvent.user, text: taskText, + slackMessageContext: originalEvent.agentContext, ackEmoji: prefill.processingReactionName, ts: originalEvent.ts, threadTs: threadId, @@ -3233,6 +3242,7 @@ export async function handleRetryFailedTask( undefined, slackUserId: originalPayload.user, text: originalPayload.text, + slackMessageContext: originalPayload.slackMessageContext, agentPromptText: originalPayload.agentPromptText, ackEmoji: originalPayload.ackEmoji, completionEmoji: originalPayload.completionEmoji, diff --git a/packages/slack/src/forwarded-message-context.ts b/packages/slack/src/forwarded-message-context.ts index 08918473f..82781c78c 100644 --- a/packages/slack/src/forwarded-message-context.ts +++ b/packages/slack/src/forwarded-message-context.ts @@ -1101,6 +1101,27 @@ export function appendSlackAttachmentContext( attachments?: unknown[], blocks?: unknown[], ): string { + const attachmentContext = formatSlackAttachmentContext( + text, + attachments, + blocks, + ); + + if (!attachmentContext) { + return text; + } + + const normalizedText = text.trim(); + return normalizedText + ? `${normalizedText}\n\n${attachmentContext}` + : attachmentContext; +} + +export function formatSlackAttachmentContext( + text: string, + attachments?: unknown[], + blocks?: unknown[], +): string | undefined { const textWithForwardedContext = appendSlackForwardedMessageContext( text, attachments, @@ -1113,17 +1134,15 @@ export function appendSlackAttachmentContext( ); const blockLinkContext = formatSlackBlockLinkContext(blocks); const additionalContexts = [ + formatSlackForwardedMessageContext(attachments), attachmentTitleContext, blockTextContext, blockLinkContext, ].filter((context): context is string => Boolean(context)); if (additionalContexts.length === 0) { - return textWithForwardedContext; + return undefined; } - const normalizedText = textWithForwardedContext.trim(); - return normalizedText - ? `${normalizedText}\n\n${additionalContexts.join('\n\n')}` - : additionalContexts.join('\n\n'); + return additionalContexts.join('\n\n'); } diff --git a/packages/slack/src/slack-notifier.ts b/packages/slack/src/slack-notifier.ts index 5171dae1b..d8f320818 100644 --- a/packages/slack/src/slack-notifier.ts +++ b/packages/slack/src/slack-notifier.ts @@ -32,6 +32,7 @@ import { createSlackWebClient } from './web-client'; import { appendSlackAttachmentContext, appendSlackForwardedMessageFiles, + formatSlackAttachmentContext, } from './forwarded-message-context'; type SlackApiThreadMessage = { @@ -1439,8 +1440,15 @@ export class SlackNotifier { return null; } + const authoredText = + typeof message.text === 'string' ? message.text : ''; + const agentContext = formatSlackAttachmentContext( + authoredText, + message.attachments, + message.blocks, + ); const text = appendSlackAttachmentContext( - typeof message.text === 'string' ? message.text : '', + authoredText, message.attachments, message.blocks, ); @@ -1455,6 +1463,7 @@ export class SlackNotifier { return { text, + ...(agentContext ? { authoredText, agentContext } : {}), ts: message.ts, thread_ts: typeof message.thread_ts === 'string' @@ -1468,7 +1477,7 @@ export class SlackNotifier { attachments: Array.isArray(message.attachments) ? message.attachments : undefined, - blocks: Array.isArray(message.blocks) ? message.blocks : undefined, + ...(Array.isArray(message.blocks) ? { blocks: message.blocks } : {}), files, }; }; diff --git a/packages/slack/src/slack-thread-delivery-tracker.ts b/packages/slack/src/slack-thread-delivery-tracker.ts index a045859ea..1a3bded47 100644 --- a/packages/slack/src/slack-thread-delivery-tracker.ts +++ b/packages/slack/src/slack-thread-delivery-tracker.ts @@ -21,6 +21,7 @@ import { isSlackRoutingWaitReplyText } from './slack-system-messages'; interface SlackThreadContinuationPromptOptions { currentMessageTs: string; currentMessageText: string; + currentMessageAgentContext?: string; excludedContextTimestamps?: string[]; resolveCurrentMessageText?: ( claimedMessages: SlackThreadMessage[], @@ -76,6 +77,7 @@ export class SlackThreadDeliveryTracker { public async buildContinuationPrompt({ currentMessageTs, currentMessageText, + currentMessageAgentContext, excludedContextTimestamps, resolveCurrentMessageText, fetchThreadMessages, @@ -211,6 +213,7 @@ export class SlackThreadDeliveryTracker { }; const currentMessageBlock = wrapSlackMessage(resolvedCurrentMessageText, { ts: currentMessageTs, + agentContext: currentMessageAgentContext, }); if (!contextBlock && !normalizedLatestBotReply) { @@ -245,6 +248,16 @@ export class SlackThreadDeliveryTracker { return { currentMessageText, claimedImageUris: [], + formattedPrompt: [ + wrapSlackTurnPolicy({ + reactionsAllowed: false, + preferEmojiAck: false, + }), + wrapSlackMessage(currentMessageText, { + ts: currentMessageTs, + agentContext: currentMessageAgentContext, + }), + ].join('\n\n'), turnPolicy: { reactionsAllowed: false, }, diff --git a/packages/slack/src/start-auto-routed-slack-task.ts b/packages/slack/src/start-auto-routed-slack-task.ts index 0383ef6d5..319b9b08c 100644 --- a/packages/slack/src/start-auto-routed-slack-task.ts +++ b/packages/slack/src/start-auto-routed-slack-task.ts @@ -145,6 +145,7 @@ export async function startAutoRoutedSlackTask({ initiatingSlackUserId, channel, prompt, + slackMessageContext, threadTs, originMessageTs, processedImages, @@ -186,6 +187,7 @@ export async function startAutoRoutedSlackTask({ initiatingSlackUserId?: string; channel: string; prompt: string; + slackMessageContext?: string; threadTs?: string; originMessageTs?: string; processedImages?: string[]; @@ -354,7 +356,6 @@ export async function startAutoRoutedSlackTask({ text: taskDescription, attachmentTexts: allAttachmentTexts, }); - const channelName = (await slack.getChannelName?.(channel)) ?? undefined; const routingContext = await buildSlackRoutingContext({ @@ -501,6 +502,7 @@ export async function startAutoRoutedSlackTask({ slackUserId, persistedSlackUserId, text: taskText, + slackMessageContext, agentPromptText, ts: sourceMessageTs, threadTs: threadId, diff --git a/packages/slack/src/start-slack-app-mention.ts b/packages/slack/src/start-slack-app-mention.ts index 17fafda21..657a4ae15 100644 --- a/packages/slack/src/start-slack-app-mention.ts +++ b/packages/slack/src/start-slack-app-mention.ts @@ -65,6 +65,7 @@ function getLinkedInitiatorUserId( function buildActiveSlackFollowUpPrompt(input: { text: string; + agentContext?: string; ts: string; threadMessages?: SlackThreadMessage[]; latestOwnBotReplyText?: string; @@ -105,7 +106,10 @@ function buildActiveSlackFollowUpPrompt(input: { reactionsAllowed: hasPriorBotReply, preferEmojiAck: hasPriorBotReply, }), - wrapSlackMessage(input.text, { ts: input.ts }), + wrapSlackMessage(input.text, { + ts: input.ts, + agentContext: input.agentContext, + }), ] .filter(Boolean) .join('\n\n'), @@ -130,6 +134,7 @@ export async function startSlackAppMentionTask(input: { slackUserId: string; persistedSlackUserId?: string | null; text: string; + slackMessageContext?: string; agentPromptText?: string; /** * Deprecated: acknowledgement/completion reactions are fixed defaults and @@ -195,6 +200,7 @@ export async function startSlackAppMentionTask(input: { const builtPrompt = !agentPromptText ? buildActiveSlackFollowUpPrompt({ text: input.text, + agentContext: input.slackMessageContext, ts: input.ts, threadMessages: promptRelevantThreadMessages, latestOwnBotReplyText: promptRelevantLatestOwnBotReply?.text, @@ -278,6 +284,9 @@ export async function startSlackAppMentionTask(input: { ? {} : { user: input.persistedSlackUserId ?? input.slackUserId }), text: input.text, + ...(input.slackMessageContext?.trim() + ? { slackMessageContext: input.slackMessageContext.trim() } + : {}), ...(input.agentPromptText?.trim() ? { agentPromptText: input.agentPromptText.trim() } : {}), diff --git a/packages/slack/src/types.ts b/packages/slack/src/types.ts index befaf89c7..95b29165e 100644 --- a/packages/slack/src/types.ts +++ b/packages/slack/src/types.ts @@ -67,6 +67,7 @@ export interface SlackChannelMessage extends SlackThreadMessage { export interface SlackConversationMessage { text: string; authoredText?: string; + agentContext?: string; ts: string; thread_ts?: string; user?: string; @@ -100,6 +101,7 @@ export interface SlackEvent { user: string; text: string; authoredText?: string; + agentContext?: string; ts: string; /** Internal turn ID when an event is synthesized from another Slack event. */ deliveryTs?: string; diff --git a/packages/types/src/__tests__/acp.test.ts b/packages/types/src/__tests__/acp.test.ts index 139a3f740..b40efd8ff 100644 --- a/packages/types/src/__tests__/acp.test.ts +++ b/packages/types/src/__tests__/acp.test.ts @@ -406,6 +406,39 @@ describe('normalizeTranscriptUserText', () => { ).toBe('latest question'); }); + it('hides agent-only Slack message context from transcript text', () => { + expect( + normalizeTranscriptUserText( + [ + '', + 'Emoji reactions are not allowed.', + '', + '', + '', + 'Slack block text:', + 'State: New', + '', + '', + '', + 'latest question', + '', + ].join('\n'), + ), + ).toBe('latest question'); + }); + + it('leaves malformed Slack message context visible instead of stripping arbitrary text', () => { + const text = [ + '', + 'Slack block text without a closing context tag', + '', + 'latest question', + '', + ].join('\n'); + + expect(normalizeTranscriptUserText(text)).toBe(text); + }); + it('extracts the current Slack turn when the prompt wrappers are HTML-escaped', () => { expect( normalizeTranscriptUserText( @@ -418,6 +451,10 @@ describe('normalizeTranscriptUserText', () => { 'Roomote Bot: Previous reply', '</replying_to>', '', + '<slack_message_context>', + 'Slack block text: State: New', + '</slack_message_context>', + '', '<slack_message ts="111.000">', 'latest question', '</slack_message>', diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts index 50680e74a..473c5c405 100644 --- a/packages/types/src/acp.ts +++ b/packages/types/src/acp.ts @@ -1420,7 +1420,7 @@ function isSlackThreadActivityOnlyBlock(text: string): boolean { /** * Extract slack_message content from: * thread_activity* thread_context? thread_activity* - * replying_to? slack_turn_policy? slack_message + * replying_to? slack_turn_policy? slack_message_context? slack_message * * Implemented as the original recursive descent with per-(pos, phase) memoization, * executed on an explicit heap stack so thousands of sequential activity blocks @@ -1486,17 +1486,43 @@ function parseSlackRestFrom(text: string, index: number): string | null { afterPolicyOpen + 1, closeMarker, (_close, afterClose) => - extractSlackMessageAt(text, afterClose) !== null, + extractSlackMessageAfterOptionalContext(text, afterClose) !== null, ); if (found !== null) { - return extractSlackMessageAt(text, found.afterClose); + return extractSlackMessageAfterOptionalContext(text, found.afterClose); } } - return extractSlackMessageAt(text, at); + return extractSlackMessageAfterOptionalContext(text, at); } } +function extractSlackMessageAfterOptionalContext( + text: string, + index: number, +): string | null { + const afterContextOpen = matchOpenTag( + text, + index, + 'slack_message_context', + false, + ); + if (afterContextOpen !== null && text[afterContextOpen] === '\n') { + const closeMarker = '\n'; + const found = findStructuralClose( + text, + afterContextOpen + 1, + closeMarker, + (_close, afterClose) => extractSlackMessageAt(text, afterClose) !== null, + ); + if (found !== null) { + return extractSlackMessageAt(text, found.afterClose); + } + } + + return extractSlackMessageAt(text, index); +} + function parseSlackTranscriptFrom(text: string, start: number): string | null { // phase: 0 = pre-context (leading activities), 1 = post-context (trailing + rest) type Frame = { diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index 1dac1eee6..cf5d7f637 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -1235,6 +1235,7 @@ export const slackAppMentionSchema = sharedTaskSchema.extend({ user: z.string().optional(), text: z.string(), agentPromptText: z.string().optional(), + slackMessageContext: z.string().optional(), /** * Optional acknowledgement emoji name that was applied to the source * message when the task was kicked off.