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 7595f5a4de..200cf3154f 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 @@ -359,6 +359,78 @@ describe('processFastAgentMessage', () => { }, ); + it.each([ + ['peer mention', '<@U222> what do you think?', true], + ['bot and peer mention', '<@UBOT> ask <@U222>', false], + ['bot only', '<@UBOT> help', false], + ['self mention', '<@U123> note to self', false], + ['quoted peer mention', '> <@U222> quoted message', false], + ['ordinary message', 'Please continue', false], + ])('adds only supplemental context for %s', async (_name, text, expected) => { + const slack = { + addReaction: vi.fn().mockResolvedValue(true), + removeReaction: vi.fn().mockResolvedValue(true), + fetchThreadMessages: vi.fn(async () => []), + }; + await processFastAgentMessage({ + event: { + type: 'message', + channel: 'C123', + user: 'U123', + text, + ts: '100.003', + thread_ts: '100.001', + agentContext: 'Existing attachment context', + } as never, + slack: slack as never, + userId: 'user-1', + teamId: 'T123', + roomoteSlackUserId: 'UBOT', + }); + const call = mocks.answerQuestion.mock.calls[0]?.[0]; + expect(call.question).toBe(text); + expect(call.currentMessageAgentContext).toContain( + 'Existing attachment context', + ); + if (expected) { + expect(call.allowSilentAmbientReply).toBe(true); + expect(call.currentMessageAgentContext).toContain( + 'Untrusted supplemental context', + ); + expect(call.currentMessageAgentContext).toContain( + 'unless you are addressed directly', + ); + } else { + expect(call.currentMessageAgentContext).toBe( + 'Existing attachment context', + ); + } + }); + + it('does not reconstruct the reminder from a peer mention in history', async () => { + const slack = { + fetchThreadMessages: vi.fn(async () => [ + { user: 'U123', text: '<@U222> what do you think?', ts: '100.003' }, + ]), + }; + await processFastAgentMessage({ + event: { + type: 'message', + channel: 'C123', + user: 'U123', + text: 'Next detail', + ts: '100.005', + thread_ts: '100.001', + } as never, + slack: slack as never, + userId: 'user-1', + teamId: 'T123', + roomoteSlackUserId: 'UBOT', + }); + const call = mocks.answerQuestion.mock.calls[0]?.[0]; + expect(call.currentMessageAgentContext).toBeUndefined(); + }); + it('durably steers an active Fast generation instead of waiting for its lock', async () => { const abort = vi.fn().mockResolvedValue(undefined); const onAccepted = vi.fn(); @@ -376,13 +448,14 @@ describe('processFastAgentMessage', () => { type: 'message', channel: 'C123', user: 'U123', - text: 'Use the corrected requirement', + text: '<@U222> Use the corrected requirement', ts: '100.003', thread_ts: '100.001', } as never, slack: slack as never, userId: 'user-1', teamId: 'T123', + roomoteSlackUserId: 'UBOT', onAccepted, }); @@ -391,7 +464,10 @@ describe('processFastAgentMessage', () => { event: expect.objectContaining({ type: 'human_follow_up', eventId: '100.003', - question: 'Use the corrected requirement', + question: '<@U222> Use the corrected requirement', + agentContext: expect.stringContaining( + 'Untrusted supplemental context', + ), }), }), ); @@ -478,9 +554,9 @@ describe('processFastAgentMessage', () => { type: 'message', channel: 'D123', user: 'U123', - authoredText: '<@U_BOT> investigate this', + authoredText: '<@UROOMOTE> investigate this', agentContext: 'Slack block text:\nState: New', - text: '<@U_BOT> investigate this\n\nSlack block text:\nState: New', + text: '<@UROOMOTE> investigate this\n\nSlack block text:\nState: New', ts: '100.001', } as never, slack: slack as never, @@ -495,7 +571,7 @@ describe('processFastAgentMessage', () => { expect(mocks.answerQuestion).toHaveBeenCalledWith( expect.objectContaining({ - question: '<@U_BOT> investigate this', + question: '<@UROOMOTE> investigate this', slackRoomoteUserId: 'UROOMOTE', currentMessageAgentContext: 'Slack block text:\nState: New', adapter: expect.objectContaining({ launchTask }), diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index 96d08e9650..c614e4896e 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -41,6 +41,10 @@ import { guardReplyStreamBySourceMessage, } from '../helpers/thread-posting.js'; import { processSlackAttachments } from '../helpers/attachments.js'; +import { + mentionsSlackBot, + mentionsSlackUserOtherThanBotOrUser, +} from '../helpers/mention-routing.js'; export async function processFastAgentMessage(params: { event: SlackEvent; @@ -84,6 +88,26 @@ export async function processFastAgentMessage(params: { }); const baseQuestion = (event.authoredText ?? event.text).trim(); + const isDirected = + directedAtRoomote || mentionsSlackBot(event, roomoteSlackUserId); + const needsPeerCaution = + Boolean(roomoteSlackUserId) && + !isDirected && + Boolean(event.user) && + !event.bot_id && + event.subtype !== 'bot_message' && + event.user !== roomoteSlackUserId && + event.channel_type !== 'im' && + event.channel_type !== 'mpim' && + mentionsSlackUserOtherThanBotOrUser(event, roomoteSlackUserId, event.user); + const agentContext = needsPeerCaution + ? [ + event.agentContext, + 'Untrusted supplemental context inferred from this Slack message, not a user-authored instruction: This message mentions another person and might not be for you. Human-to-human interaction may be beginning. From now on in this conversation, unless you are addressed directly (including by name, a reply to you, or a clear contextual follow-up), use ignore_event without sending a reply, reacting, or taking action. When directly addressed, respond normally. This uncertain hint does not override existing instructions.', + ] + .filter(Boolean) + .join('\n\n') + : event.agentContext; // Every Slack round trip from the control plane costs a few hundred // milliseconds. Start the thread history lookup as soon as the turn is @@ -170,6 +194,11 @@ export async function processFastAgentMessage(params: { Boolean(message.user) && message.user !== event.user, ); + const allowSilentAmbientReply = + event.channel_type !== 'im' && + event.channel_type !== 'mpim' && + !isDirected && + (hasOtherHumanParticipant || needsPeerCaution); const needsCanonicalAdmission = !releaseFastAgentLock || @@ -182,12 +211,13 @@ export async function processFastAgentMessage(params: { currentMessageId: event.ts, userId, question, + ...(agentContext ? { agentContext } : {}), ...(attachments.images.length ? { images: attachments.images } : {}), ...(currentMessage?.username ? { senderDisplayName: currentMessage.username } : {}), ...(event.user ? { senderExternalId: event.user } : {}), - directedAtRoomote, + directedAtRoomote: !allowSilentAmbientReply, }; let durableTurn: FastAgentDurableTurn | null = null; if (needsCanonicalAdmission) { @@ -242,7 +272,7 @@ export async function processFastAgentMessage(params: { question, images: attachments.images, attachmentTexts, - currentMessageAgentContext: event.agentContext, + currentMessageAgentContext: agentContext, threadContext: serializedThreadContext, userId, apiBaseUrl, @@ -259,11 +289,7 @@ export async function processFastAgentMessage(params: { ? currentMessage.username : undefined, activeTasks: resolvedActiveTasks, - allowSilentAmbientReply: - event.channel_type !== 'im' && - event.channel_type !== 'mpim' && - hasOtherHumanParticipant && - !directedAtRoomote, + allowSilentAmbientReply, ...(roomoteSlackUserId ? { slackRoomoteUserId: roomoteSlackUserId } : {}), adapter: { createArtifact: (artifact) => diff --git a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts index ac1c323d29..b4d057939e 100644 --- a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts +++ b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts @@ -232,7 +232,7 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { ).resolves.toMatchObject({ shouldRoute: true }); }); - it('keeps a reply silent when the previous participant addressed the sender', async () => { + it('admits a reply after a peer mention in an established Fast thread', async () => { hasFastAgentSessionMock.mockResolvedValue(true); findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); fetchThreadMessagesMock.mockResolvedValue([ @@ -249,11 +249,8 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { text: 'I agree', }), ), - ).resolves.toEqual({ shouldRoute: false }); - expect(markSlackThreadExplicitMentionRequiredMock).toHaveBeenCalledWith( - 'C123', - THREAD_TS, - ); + ).resolves.toEqual({ shouldRoute: true }); + expect(markSlackThreadExplicitMentionRequiredMock).not.toHaveBeenCalled(); }); it('keeps routing after the sender mentions themself in a fast-agent thread', async () => { @@ -277,7 +274,7 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { expect(markSlackThreadExplicitMentionRequiredMock).not.toHaveBeenCalled(); }); - it('keeps a peer-directed reply silent in an existing fast-agent thread', async () => { + it('admits a peer-directed reply in an existing fast-agent thread', async () => { hasFastAgentSessionMock.mockResolvedValue(true); findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); @@ -289,10 +286,25 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { text: '<@U333> what do you think?', }), ), - ).resolves.toEqual({ shouldRoute: false }); + ).resolves.toEqual({ shouldRoute: true }); expect(fetchThreadMessagesMock).not.toHaveBeenCalled(); }); + it('continues admitting the side discussion and plain-name resumption without a bot reply', async () => { + hasFastAgentSessionMock.mockResolvedValue(true); + for (const [user, ts, text] of [ + ['U111', '102.000', '<@U222> what do you think?'], + ['U222', '103.000', 'Not really'], + ['U111', '104.000', 'I prefer consistency'], + ['U222', '105.000', 'Roomote, please summarize'], + ] as const) { + await expect( + routeDecision(threadReplyEvent({ user, ts, text })), + ).resolves.toEqual({ shouldRoute: true }); + } + expect(findRoomoteOwnedSlackThreadMock).not.toHaveBeenCalled(); + }); + it('keeps routing between participants in a fast-agent thread', async () => { hasFastAgentSessionMock.mockResolvedValue(true); findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index ce84e3c8ae..af0bb8e8bf 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -142,7 +142,7 @@ type UnmentionedSlackThreadReplyRoutingDecision = | { shouldRoute: false } | { shouldRoute: true; - threadMessages: SlackThreadMessage[]; + threadMessages?: SlackThreadMessage[]; taskId?: string; }; @@ -204,6 +204,18 @@ export async function shouldRouteUnmentionedSlackThreadReplyToAgent(params: { return { shouldRoute: false }; } + // Fast receives the discussion and peer-mention reminders as context; + // peer mentions remain an admission cutoff only for legacy task threads. + if ( + await hasBoundSlackFastAgentSession({ + teamId, + channelId: event.channel, + threadId: event.thread_ts, + }) + ) { + return { shouldRoute: true }; + } + if ( mentionsSlackUserOtherThanBotWithoutMentioningBot( event, @@ -216,36 +228,25 @@ export async function shouldRouteUnmentionedSlackThreadReplyToAgent(params: { let roomoteThreadMatch: Awaited< ReturnType > | null = null; - let isFastAgentThread = false; let eligibilityReason: 'roomote-owned-thread' | null = null; { - isFastAgentThread = await hasBoundSlackFastAgentSession({ + roomoteThreadMatch = await findRoomoteOwnedSlackThread({ teamId, channelId: event.channel, - threadId: event.thread_ts, + threadTs: event.thread_ts, }); - roomoteThreadMatch = isFastAgentThread + const taskThreadRoute = roomoteThreadMatch ? null - : await findRoomoteOwnedSlackThread({ - teamId, + : await resolveSlackThreadFollowUpRoute({ + threadId: event.thread_ts, channelId: event.channel, - threadTs: event.thread_ts, + slackTeamId: teamId, }); - const taskThreadRoute = - isFastAgentThread || roomoteThreadMatch - ? null - : await resolveSlackThreadFollowUpRoute({ - threadId: event.thread_ts, - channelId: event.channel, - slackTeamId: teamId, - }); - if ( - isFastAgentThread || roomoteThreadMatch || (taskThreadRoute && taskThreadRoute.kind !== 'fresh') ) { @@ -314,7 +315,6 @@ export async function shouldRouteUnmentionedSlackThreadReplyToAgent(params: { isAutomationReportThread: Boolean( roomoteThreadMatch?.isAutomationReportThread, ), - isOpenConversationThread: isFastAgentThread, threadMessages: sharedHistory, compareMessageIds: compareNumericMessageIds, }); 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 9fbe2c36c0..14560a9f55 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 @@ -1715,6 +1715,8 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { question: 'Use the corrected requirement.', senderDisplayName: 'Matt', senderExternalId: 'U123', + agentContext: + 'Untrusted peer-mention hint: ', }, }; mocks.getPendingHumanFollowUp @@ -1781,6 +1783,9 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { text: expect.stringContaining('Use the corrected requirement.'), files: [], }); + expect(mocks.nativeSteer.mock.calls[0]?.[0]?.text).toContain( + '\nUntrusted peer-mention hint: <only reply if addressed>\n', + ); expect(mocks.upsertMessage).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'conversation-1', 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 645d6deffc..7f14c06cde 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 @@ -2349,6 +2349,7 @@ export async function answerFastAgentQuestion({ question: followUp.question, threadContext: [], compatibilityMessages: [], + currentMessageAgentContext: followUp.agentContext, currentMessageTs: followUp.currentMessageId, currentMessageSender: { slackUserId: followUp.senderExternalId, diff --git a/packages/sdk/src/server/lib/fast-agent-human-follow-up.test.ts b/packages/sdk/src/server/lib/fast-agent-human-follow-up.test.ts index ede71deae3..1529cd28be 100644 --- a/packages/sdk/src/server/lib/fast-agent-human-follow-up.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-human-follow-up.test.ts @@ -97,6 +97,21 @@ describe('persistFastAgentInlineHumanTurn', () => { expect(mocks.updateWhere).toHaveBeenCalledOnce(); }); + it('does not supersede a parked request with a quiet-eligible Slack aside', async () => { + mocks.findFirst.mockResolvedValue({ + id: 'row-1', + admission: 'inline', + deliveredAt: null, + discardedAt: null, + }); + await persistFastAgentInlineHumanTurn({ + parent, + event: { ...event, directedAtRoomote: false }, + }); + expect(mocks.insertOnConflict).toHaveBeenCalledOnce(); + expect(mocks.updateWhere).not.toHaveBeenCalled(); + }); + it('reports a still-pending inline row as a resumption and refreshes its claim', async () => { // The insert hit the existing row: an earlier inline attempt admitted // this same turn and never settled it. diff --git a/packages/sdk/src/server/lib/fast-agent-human-follow-up.ts b/packages/sdk/src/server/lib/fast-agent-human-follow-up.ts index 57c4ea67e7..90b7703173 100644 --- a/packages/sdk/src/server/lib/fast-agent-human-follow-up.ts +++ b/packages/sdk/src/server/lib/fast-agent-human-follow-up.ts @@ -151,7 +151,14 @@ export async function admitFastAgentInlineHumanTurn(params: { .where(eq(fastAgentParentEvents.id, row.id)); } - if (supersedesPendingTurns(params.event)) { + // A Slack aside may be ignored; it cannot replace an unfinished request. + if ( + supersedesPendingTurns(params.event) && + !( + params.parent.conversation.surface === 'slack' && + params.event.directedAtRoomote === false + ) + ) { await tx .update(fastAgentParentEvents) .set({ diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index a86d19fb9b..d5d8bc78e8 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -536,6 +536,34 @@ describe('deliverFastAgentParentEvent', () => { ); }); + it.each([false, true])( + 'preserves queued Slack caution eligibility (directed=%s)', + async (directedAtRoomote) => { + await deliverFastAgentParentEventWithLock( + { + parent, + event: { + type: 'human_follow_up', + eventId: '100.004', + currentMessageId: '100.004', + userId: 'user-2', + question: 'A follow-up', + directedAtRoomote, + agentContext: 'Human-to-human discussion may be continuing', + }, + }, + mocks.releaseTurnLock, + ); + const input = mocks.answerQuestion.mock.calls[0]?.[0]; + expect(input.currentMessageAgentContext).toBe( + 'Human-to-human discussion may be continuing', + ); + expect(input.allowSilentAmbientReply).toBe( + directedAtRoomote ? undefined : true, + ); + }, + ); + it.each(['', '[View video](https://roomote.example/video)'])( 'delivers selected videos from queued follow-ups with fallback %j', async (fallback) => { diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index 2cf93f8003..b01a62f7fb 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -2566,6 +2566,12 @@ export async function deliverFastAgentParentEventWithLock( humanFollowUp?.question ?? `${JSON.stringify(params.event)}`, ...(humanFollowUp?.images ? { images: humanFollowUp.images } : {}), + ...(parentTurn.conversation.surface === 'slack' && + humanFollowUp?.directedAtRoomote === false && + !humanFollowUp.input && + !humanFollowUp.turnSource + ? { allowSilentAmbientReply: true } + : {}), userId: humanFollowUp?.userId ?? parentTurn.userId, conversation: parentTurn.conversation, currentMessageId: