From f9cc1e53467c061dd4061d1f6829ba217970d2fb Mon Sep 17 00:00:00 2001
From: "@mrubens" <2600+mrubens@users.noreply.github.com>
Date: Sat, 29 Aug 2026 04:38:15 +0000
Subject: [PATCH 1/2] fix: avoid reacting to inbound Slack reactions
---
.../server/fast-agent/__tests__/fast-agent-prompt.test.ts | 7 +++++++
.../src/server/fast-agent/fast-agent-prompt.ts | 2 +-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts
index b4995a9f5..91667e4b0 100644
--- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts
+++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts
@@ -502,6 +502,13 @@ describe('buildFastAgentSystemPrompt', () => {
'Call "ignore_event" only when the event is duplicate, lifecycle-only, machinery-only, or a routine log that adds nothing useful',
);
expect(prompt).not.toContain('Do not call "ignore_event"');
+ expect(prompt).toContain('Do not use the reaction tool');
+ expect(prompt).toContain(
+ 'an inbound Slack reaction event is not itself a reactable message surface',
+ );
+ expect(prompt).toContain(
+ 'If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above',
+ );
});
it('requires a visible closeout for visibility-required platform events', () => {
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
index d270fa312..64e91011e 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
@@ -257,7 +257,7 @@ ${
: '- No failed-start retry tool is available for this event. Report or ignore it without retrying.'
}
- Launching creates a separate delegated task; it does not retry the task associated with this event.
-- Do not use the reaction tool because a platform event has no incoming chat message to react to.
+- Do not use the reaction tool because a platform event has no incoming chat message to react to. In particular, an inbound Slack reaction event is not itself a reactable message surface. If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above.
${
platformEventKind === 'automation'
? `- Execute the automation prompt now. Use integrations directly when sufficient, and launch a task only when repository or workspace execution is actually required. The configured model is a delegated-task default, not the Fast inference model.
From c151126b7782f144a982f66cc103af92e740d347 Mon Sep 17 00:00:00 2001
From: "@mrubens" <2600+mrubens@users.noreply.github.com>
Date: Sat, 29 Aug 2026 05:18:00 +0000
Subject: [PATCH 2/2] feat: support Fast reply reactions across chat providers
---
.changeset/fast-reply-reactions.md | 5 +
.../handlers/discord/__tests__/index.test.ts | 104 +++++++++++++
apps/api/src/handlers/discord/index.ts | 93 ++++++++++-
.../slack/events/fast-agent-reaction.test.ts | 22 ++-
.../slack/events/fast-agent-reaction.ts | 39 ++---
.../handlers/teams/__tests__/index.test.ts | 146 ++++++++++++++++++
apps/api/src/handlers/teams/index.ts | 104 ++++++++++++-
.../handlers/telegram/__tests__/index.test.ts | 120 ++++++++++++++
apps/api/src/handlers/telegram/index.ts | 104 ++++++++++++-
apps/docs/automations.mdx | 5 +
apps/docs/communications.mdx | 20 +++
.../providers/communications/telegram.mdx | 6 +-
.../__tests__/fast-agent-prompt.test.ts | 2 +-
.../__tests__/fast-agent-service.test.ts | 34 ++++
.../fast-agent/fast-agent-conversation.ts | 21 +++
.../server/fast-agent/fast-agent-prompt.ts | 2 +-
.../server/fast-agent/fast-agent-service.ts | 7 +
.../src/__tests__/teams-activity.test.ts | 17 ++
.../src/__tests__/telegram-update.test.ts | 15 ++
packages/communication/src/teams-activity.ts | 9 ++
packages/communication/src/telegram-update.ts | 21 +++
.../lib/fast-agent-provider-message.test.ts | 57 ++++++-
.../server/lib/fast-agent-provider-message.ts | 32 ++++
.../lib/fast-agent-surface-reply.test.ts | 117 ++++++++++++++
.../server/lib/fast-agent-surface-reply.ts | 100 +++++++-----
25 files changed, 1118 insertions(+), 84 deletions(-)
create mode 100644 .changeset/fast-reply-reactions.md
diff --git a/.changeset/fast-reply-reactions.md b/.changeset/fast-reply-reactions.md
new file mode 100644
index 000000000..e9be22025
--- /dev/null
+++ b/.changeset/fast-reply-reactions.md
@@ -0,0 +1,5 @@
+---
+"@roomote/api": patch
+---
+
+Let linked Fast session owners respond to Roomote replies with emoji reactions across Slack, Discord, Microsoft Teams, and Telegram.
diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts
index 2cebcf495..a8dd25219 100644
--- a/apps/api/src/handlers/discord/__tests__/index.test.ts
+++ b/apps/api/src/handlers/discord/__tests__/index.test.ts
@@ -62,9 +62,11 @@ const mocks = vi.hoisted(() => ({
acquireFastTurnLock: vi.fn(),
answerFast: vi.fn(),
hasFastSession: vi.fn(),
+ findFastMessageSession: vi.fn(),
findFastReplySession: vi.fn(),
isFastProviderMessage: vi.fn(),
recordProviderMessage: vi.fn(),
+ queueFastSurfaceReply: vi.fn(),
}));
vi.mock('../../account-link-help.js', () => ({
@@ -110,9 +112,11 @@ vi.mock('@roomote/sdk/server', () => ({
upsertDiscordInstallation: mocks.upsertInstallation,
enqueueDiscordGatewayEvent: mocks.enqueueGatewayEvent,
claimPendingPrReviewActionsForThread: vi.fn(async () => []),
+ findFastAgentSessionForProviderMessage: mocks.findFastMessageSession,
findFastAgentSessionForProviderReply: mocks.findFastReplySession,
isFastAgentProviderMessage: mocks.isFastProviderMessage,
recordFastAgentConversationMessageBestEffort: mocks.recordProviderMessage,
+ queueFastAgentSurfaceReply: mocks.queueFastSurfaceReply,
resolveUserMcpServerConfigs: vi.fn(async () => ({})),
}));
@@ -182,6 +186,10 @@ vi.mock('../callback-actions.js', () => ({
vi.mock('@roomote/cloud-agents/server', () => ({
acquireFastAgentTurnLock: mocks.acquireFastTurnLock,
answerFastAgentQuestion: mocks.answerFast,
+ buildFastAgentReactionExternalInputQuestion: vi.fn(
+ (input: unknown) =>
+ `${JSON.stringify(input)}`,
+ ),
resolveApiBaseUrl: () => 'https://roomote.example.com',
getTaskUrl: mocks.getTaskUrl,
hasFastAgentSession: mocks.hasFastSession,
@@ -323,9 +331,11 @@ describe('Discord Gateway event handler', () => {
);
mocks.answerFast.mockResolvedValue('A quick answer');
mocks.hasFastSession.mockResolvedValue(false);
+ mocks.findFastMessageSession.mockResolvedValue(null);
mocks.findFastReplySession.mockResolvedValue(null);
mocks.isFastProviderMessage.mockResolvedValue(false);
mocks.recordProviderMessage.mockResolvedValue(true);
+ mocks.queueFastSurfaceReply.mockResolvedValue(true);
mocks.reply.mockResolvedValue({ messageId: 'reply-1' });
mocks.createDirectMessage.mockResolvedValue({ id: 'dm-private-1' });
mocks.createThreadFromMessage.mockResolvedValue({
@@ -509,6 +519,7 @@ describe('Discord Gateway event handler', () => {
);
expect(mocks.startNewTask).not.toHaveBeenCalled();
expect(mocks.queueMessage).not.toHaveBeenCalled();
+ expect(mocks.queueFastSurfaceReply).not.toHaveBeenCalled();
});
it('answers a configured reaction through the fast agent when the reacted-on message cannot be fetched', async () => {
@@ -591,6 +602,99 @@ describe('Discord Gateway event handler', () => {
}),
);
expect(mocks.callViaEmojiConfig).not.toHaveBeenCalled();
+ expect(mocks.queueFastSurfaceReply).not.toHaveBeenCalled();
+ });
+
+ it('queues an unconfigured reaction on the owner’s bound Fast message', async () => {
+ mocks.getChannel.mockResolvedValue({
+ id: 'thread-1',
+ name: 'Task thread',
+ type: 11,
+ guildId: 'guild-1',
+ parentId: 'channel-1',
+ });
+ mocks.findFastMessageSession.mockResolvedValue({
+ id: 'fast-session-1',
+ userId: 'roomote-user-1',
+ conversation: {
+ surface: 'discord',
+ workspaceId: 'guild-1',
+ conversationId: 'thread-1',
+ replyTarget: { channelId: 'channel-1', threadId: 'thread-1' },
+ },
+ });
+
+ const response = await postEvent({
+ eventId: 'thread-1:message-1:discord-user-1:heart',
+ eventType: 'MESSAGE_REACTION_ADD',
+ receivedAt: '2026-07-12T15:00:00.000Z',
+ payload: {
+ user_id: 'discord-user-1',
+ channel_id: 'thread-1',
+ message_id: 'message-1',
+ guild_id: 'guild-1',
+ emoji: { id: null, name: 'heart' },
+ member: {
+ nick: 'Matt',
+ user: { id: 'discord-user-1', username: 'matt' },
+ },
+ },
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ fastReactionQueued: true,
+ });
+ expect(mocks.findFastMessageSession).toHaveBeenCalledWith({
+ provider: 'discord',
+ workspaceId: 'guild-1',
+ channelId: 'channel-1',
+ threadId: 'thread-1',
+ messageId: 'message-1',
+ });
+ expect(mocks.queueFastSurfaceReply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionId: 'fast-session-1',
+ userId: 'roomote-user-1',
+ currentMessageId: expect.stringContaining('discord-reaction:'),
+ replyToMessageId: 'message-1',
+ externalInput: expect.objectContaining({
+ provider: 'discord',
+ reactions: [{ name: 'heart' }],
+ }),
+ }),
+ );
+ });
+
+ it('rejects a reaction from a different Fast session owner', async () => {
+ mocks.findFastMessageSession.mockResolvedValue({
+ id: 'fast-session-1',
+ userId: 'another-roomote-user',
+ conversation: {
+ surface: 'discord',
+ workspaceId: 'dm',
+ conversationId: 'dm-1',
+ replyTarget: { channelId: 'dm-1' },
+ },
+ });
+
+ const response = await postEvent({
+ eventId: 'dm-1:message-1:discord-user-1:heart',
+ eventType: 'MESSAGE_REACTION_ADD',
+ receivedAt: '2026-07-12T15:00:00.000Z',
+ payload: {
+ user_id: 'discord-user-1',
+ channel_id: 'dm-1',
+ message_id: 'message-1',
+ emoji: { id: null, name: 'heart' },
+ },
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ ignored: 'discord_fast_session_user_mismatch',
+ });
+ expect(mocks.queueFastSurfaceReply).not.toHaveBeenCalled();
});
it('rejects an invalid Gateway secret before claiming the event', async () => {
diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts
index 23e08466c..0cb2408a9 100644
--- a/apps/api/src/handlers/discord/index.ts
+++ b/apps/api/src/handlers/discord/index.ts
@@ -25,7 +25,12 @@ import {
setLatestInboundMessageId,
} from '@roomote/communication/messages';
import { reactionEmojiMatches } from '@roomote/communication/reaction-emoji';
-import { getTaskUrl, hasFastAgentSession } from '@roomote/cloud-agents/server';
+import {
+ buildFastAgentReactionExternalInputQuestion,
+ getTaskUrl,
+ hasFastAgentSession,
+ type FastAgentReactionExternalInput,
+} from '@roomote/cloud-agents/server';
import {
MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE,
RunStatus,
@@ -37,8 +42,10 @@ import {
consumeDiscordLinkCode,
findDiscordInstallationByGuildId,
findDiscordMappedUserId,
+ findFastAgentSessionForProviderMessage,
findFastAgentSessionForProviderReply,
isFastAgentProviderMessage,
+ queueFastAgentSurfaceReply,
restoreDiscordLinkCode,
upsertDiscordInstallation,
upsertDiscordUserMapping,
@@ -308,7 +315,89 @@ async function processDiscordGatewayEvent(
reaction.emoji.name,
);
if (!configuration) {
- return { ok: true, ignored: 'reaction_not_configured' };
+ const channel = await resolveDiscordChannelContext(
+ resolved.provider,
+ reaction.channel_id,
+ );
+ const metadata = discordMetadataForChannel({
+ channel,
+ messageId: reaction.message_id,
+ });
+ const session = await findFastAgentSessionForProviderMessage({
+ provider: 'discord',
+ workspaceId: channel.guildId ?? 'dm',
+ channelId: metadata.communicationChannelId,
+ ...(metadata.communicationThreadId
+ ? { threadId: metadata.communicationThreadId }
+ : {}),
+ messageId: reaction.message_id,
+ });
+ if (!session) {
+ return { ok: true, ignored: 'reaction_not_configured' };
+ }
+
+ const senderUserId = await findDiscordMappedUserId(reaction.user_id);
+ if (!senderUserId) {
+ await promptDiscordAccountLink({
+ provider: resolved.provider,
+ applicationId: resolved.applicationId,
+ channel,
+ discordUserId: reaction.user_id,
+ replyToMessageId: reaction.message_id,
+ });
+ return { ok: true, ignored: 'discord_reactor_not_linked' };
+ }
+ if (session.userId !== senderUserId) {
+ return { ok: true, ignored: 'discord_fast_session_user_mismatch' };
+ }
+
+ const author = reaction.member?.user ?? {
+ id: reaction.user_id,
+ username: `Discord user ${reaction.user_id}`,
+ };
+ const senderDisplayName =
+ (typeof reaction.member?.nick === 'string'
+ ? reaction.member.nick
+ : undefined) ??
+ (typeof author.global_name === 'string'
+ ? author.global_name
+ : undefined) ??
+ author.username;
+ const reactionInput: FastAgentReactionExternalInput = {
+ type: 'reaction_added',
+ provider: 'discord',
+ reactions: [
+ {
+ name: reaction.emoji.name,
+ ...(reaction.emoji.id ? { id: reaction.emoji.id } : {}),
+ },
+ ],
+ reactor: {
+ externalUserId: reaction.user_id,
+ ...(senderDisplayName ? { displayName: senderDisplayName } : {}),
+ },
+ message: {
+ workspaceId: channel.guildId ?? 'dm',
+ channelId: metadata.communicationChannelId,
+ messageId: reaction.message_id,
+ ...(metadata.communicationThreadId
+ ? { threadId: metadata.communicationThreadId }
+ : {}),
+ },
+ eventId: event.eventId,
+ };
+ const queued = await queueFastAgentSurfaceReply({
+ sessionId: session.id,
+ userId: senderUserId,
+ senderDisplayName,
+ question: buildFastAgentReactionExternalInputQuestion(reactionInput),
+ currentMessageId: `discord-reaction:${event.eventId}`,
+ replyToMessageId: reaction.message_id,
+ externalInput: reactionInput,
+ });
+ return queued
+ ? { ok: true, fastReactionQueued: true }
+ : { ok: true, ignored: 'discord_fast_reaction_route_unavailable' };
}
const author = reaction.member?.user ?? {
diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts
index 9fdcc623d..d2bffcc63 100644
--- a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts
+++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts
@@ -14,6 +14,10 @@ const mocks = vi.hoisted(() => ({
vi.mock('@roomote/cloud-agents/server', () => ({
acquireFastAgentTurnLock: mocks.acquireLock,
answerFastAgentQuestion: mocks.answerQuestion,
+ buildFastAgentReactionExternalInputQuestion: vi.fn(
+ (input: unknown) =>
+ `${JSON.stringify(input)}`,
+ ),
getActiveFastAgentTasks: mocks.getActiveTasks,
}));
@@ -23,7 +27,7 @@ vi.mock('@roomote/communication', () => ({
}));
vi.mock('@roomote/sdk/server', () => ({
- findFastAgentSessionForProviderReply: mocks.findSession,
+ findFastAgentSessionForProviderMessage: mocks.findSession,
recordFastAgentConversationMessageBestEffort: mocks.recordProviderMessage,
resolveUserMcpServerConfigs: vi.fn(async () => ({})),
}));
@@ -102,7 +106,7 @@ describe('Fast Slack reaction input', () => {
provider: 'slack',
workspaceId: 'T1',
channelId: 'C1',
- replyToMessageId: '101.000',
+ messageId: '101.000',
userId: 'user-1',
});
expect(mocks.answerQuestion).toHaveBeenCalledWith(
@@ -117,14 +121,20 @@ describe('Fast Slack reaction input', () => {
platformEventTranscriptPayload: {
externalInput: expect.objectContaining({
type: 'reaction_added',
- emoji: 'eyes',
- reactor: { slackUserId: 'UALICE', displayName: '@alice' },
+ provider: 'slack',
+ reactions: [{ name: 'eyes' }],
+ reactor: {
+ externalUserId: 'UALICE',
+ displayName: '@alice',
+ },
message: expect.objectContaining({
+ workspaceId: 'T1',
channelId: 'C1',
- messageTs: '101.000',
- threadTs: '100.000',
+ messageId: '101.000',
+ threadId: '100.000',
text: 'I found the issue.',
}),
+ eventId: '102.000',
}),
},
}),
diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts
index 5e0a35547..0d91efb29 100644
--- a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts
+++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts
@@ -2,14 +2,16 @@ import { Env } from '@roomote/env';
import {
acquireFastAgentTurnLock,
answerFastAgentQuestion,
+ buildFastAgentReactionExternalInputQuestion,
getActiveFastAgentTasks,
+ type FastAgentReactionExternalInput,
} from '@roomote/cloud-agents/server';
import {
buildFastSessionReplyFooterText,
resolveFastSessionReplyFooterContext,
} from '@roomote/communication';
import {
- findFastAgentSessionForProviderReply,
+ findFastAgentSessionForProviderMessage,
recordFastAgentConversationMessageBestEffort,
resolveUserMcpServerConfigs,
} from '@roomote/sdk/server';
@@ -26,24 +28,11 @@ import type { SlackWebhookContext } from '../context.js';
import { postSlackThreadMarkdownMessage } from '../helpers/thread-posting.js';
import { lookupSlackUserMapping } from '../helpers/user-mapping.js';
-type SlackReactionInput = {
- type: 'reaction_added';
- emoji: string;
- reactor: { slackUserId: string; displayName?: string };
- message: {
- channelId: string;
- messageTs: string;
- threadTs: string;
- text: string;
- };
- eventTs: string;
-};
-
async function processFastAgentReaction(params: {
context: SlackWebhookContext;
event: SlackReactionAddedEvent;
session: NonNullable<
- Awaited>
+ Awaited>
>;
targetMessage: { text: string; thread_ts?: string };
reactorDisplayName?: string;
@@ -67,22 +56,24 @@ async function processFastAgentReaction(params: {
);
const threadTs = conversation.replyTarget.threadId;
- const reactionInput: SlackReactionInput = {
+ const reactionInput: FastAgentReactionExternalInput = {
type: 'reaction_added',
- emoji: event.reaction,
+ provider: 'slack',
+ reactions: [{ name: event.reaction }],
reactor: {
- slackUserId: event.user,
+ externalUserId: event.user,
...(params.reactorDisplayName
? { displayName: params.reactorDisplayName }
: {}),
},
message: {
+ workspaceId: context.teamId,
channelId: event.item.channel,
- messageTs: event.item.ts,
- threadTs,
+ messageId: event.item.ts,
+ threadId: threadTs,
text: params.targetMessage.text,
},
- eventTs: event.event_ts,
+ eventId: event.event_ts,
};
try {
@@ -93,7 +84,7 @@ async function processFastAgentReaction(params: {
let didSendVisibleResponse = false;
const responseText = await answerFastAgentQuestion({
- question: `${JSON.stringify(reactionInput)}`,
+ question: buildFastAgentReactionExternalInputQuestion(reactionInput),
userId: session.userId,
conversation,
currentMessageId: `slack-reaction:${event.event_ts}`,
@@ -239,11 +230,11 @@ export async function maybeRouteFastAgentReaction(params: {
});
if (!activeMapping) return false;
- const session = await findFastAgentSessionForProviderReply({
+ const session = await findFastAgentSessionForProviderMessage({
provider: 'slack',
workspaceId: context.teamId,
channelId: event.item.channel,
- replyToMessageId: event.item.ts,
+ messageId: event.item.ts,
userId: activeMapping.userId,
});
if (!session) return false;
diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts
index d278d9559..464b24090 100644
--- a/apps/api/src/handlers/teams/__tests__/index.test.ts
+++ b/apps/api/src/handlers/teams/__tests__/index.test.ts
@@ -40,6 +40,8 @@ const {
releaseClaimedOutOfBandMock,
callViaEmojiConfigMock,
continueFastReplyMock,
+ queueFastReplyMock,
+ findFastMessageSessionMock,
findFastReplySessionMock,
findTeamsConversationRouteMock,
getFastSessionMock,
@@ -103,6 +105,8 @@ const {
releaseClaimedOutOfBandMock: vi.fn(),
callViaEmojiConfigMock: vi.fn(),
continueFastReplyMock: vi.fn(),
+ queueFastReplyMock: vi.fn(),
+ findFastMessageSessionMock: vi.fn(),
findFastReplySessionMock: vi.fn(),
findTeamsConversationRouteMock: vi.fn(),
getFastSessionMock: vi.fn(),
@@ -286,12 +290,18 @@ vi.mock('@roomote/sdk/server', () => ({
}
: null,
),
+ findFastAgentSessionForProviderMessage: findFastMessageSessionMock,
findFastAgentSessionForProviderReply: findFastReplySessionMock,
findTeamsConversationRoute: findTeamsConversationRouteMock,
isFastAgentProviderMessage: isFastProviderMessageMock,
+ queueFastAgentSurfaceReply: queueFastReplyMock,
}));
vi.mock('@roomote/cloud-agents/server', () => ({
+ buildFastAgentReactionExternalInputQuestion: vi.fn(
+ (input: unknown) =>
+ `${JSON.stringify(input)}`,
+ ),
buildTeamsRoutingContext: buildTeamsRoutingContextMock,
enqueueTask: enqueueTaskMock,
getOrCreateFastAgentSession: getFastSessionMock,
@@ -370,6 +380,8 @@ describe('Teams webhook handler', () => {
beforeEach(() => {
vi.clearAllMocks();
continueFastReplyMock.mockResolvedValue(true);
+ queueFastReplyMock.mockResolvedValue(true);
+ findFastMessageSessionMock.mockResolvedValue(null);
findFastReplySessionMock.mockResolvedValue(null);
getFastSessionMock.mockResolvedValue({
id: '11111111-1111-4111-8111-111111111111',
@@ -501,6 +513,7 @@ describe('Teams webhook handler', () => {
threadTs: 'activity-root',
}),
);
+ expect(queueFastReplyMock).not.toHaveBeenCalled();
});
it('launches the exact suggested task when a linked user likes its card', async () => {
@@ -556,6 +569,139 @@ describe('Teams webhook handler', () => {
}),
);
expect(callViaEmojiConfigMock).not.toHaveBeenCalled();
+ expect(queueFastReplyMock).not.toHaveBeenCalled();
+ });
+
+ it('queues a native reaction on the owner’s bound Fast message', async () => {
+ teamsUserMappingFindFirstMock.mockResolvedValue({
+ userId: 'mapped-user-1',
+ });
+ findFastMessageSessionMock.mockResolvedValue({
+ id: 'fast-session-1',
+ userId: 'mapped-user-1',
+ conversation: {
+ surface: 'teams',
+ workspaceId: 'tenant-1',
+ conversationId: '19:conversation@thread.v2:user:mapped-user-1',
+ replyTarget: {
+ channelId: '19:conversation@thread.v2',
+ threadId: 'activity-root',
+ },
+ },
+ });
+
+ const response = await createApp().request('/teams', {
+ method: 'POST',
+ headers: {
+ authorization: 'Bearer valid-token',
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(
+ createTeamsActivity({
+ type: 'messageReaction',
+ id: 'fast-reaction-1',
+ text: undefined,
+ entities: undefined,
+ replyToId: 'fast-message-1',
+ reactionsAdded: [{ type: 'heart' }],
+ }),
+ ),
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ fastReactionQueued: true,
+ });
+ expect(findFastMessageSessionMock).toHaveBeenCalledWith({
+ provider: 'teams',
+ workspaceId: 'tenant-1',
+ channelId: '19:conversation@thread.v2',
+ messageId: 'fast-message-1',
+ });
+ expect(queueFastReplyMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionId: 'fast-session-1',
+ userId: 'mapped-user-1',
+ currentMessageId: 'teams-reaction:fast-reaction-1',
+ replyToMessageId: 'fast-message-1',
+ externalInput: expect.objectContaining({
+ provider: 'teams',
+ reactions: [{ name: 'heart' }],
+ }),
+ }),
+ );
+ });
+
+ it('rejects a reaction from a different Fast session owner', async () => {
+ teamsUserMappingFindFirstMock.mockResolvedValue({
+ userId: 'mapped-user-1',
+ });
+ findFastMessageSessionMock.mockResolvedValue({
+ id: 'fast-session-1',
+ userId: 'another-user',
+ conversation: {
+ surface: 'teams',
+ workspaceId: 'tenant-1',
+ conversationId: '19:conversation@thread.v2:user:another-user',
+ replyTarget: {
+ channelId: '19:conversation@thread.v2',
+ threadId: 'activity-root',
+ },
+ },
+ });
+
+ const response = await createApp().request('/teams', {
+ method: 'POST',
+ headers: {
+ authorization: 'Bearer valid-token',
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(
+ createTeamsActivity({
+ type: 'messageReaction',
+ id: 'fast-reaction-owner-mismatch',
+ text: undefined,
+ entities: undefined,
+ replyToId: 'fast-message-1',
+ reactionsAdded: [{ type: 'laugh' }],
+ }),
+ ),
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ queued: false,
+ reason: 'fast_session_user_mismatch',
+ });
+ expect(queueFastReplyMock).not.toHaveBeenCalled();
+ });
+
+ it('ignores reaction removals', async () => {
+ const response = await createApp().request('/teams', {
+ method: 'POST',
+ headers: {
+ authorization: 'Bearer valid-token',
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(
+ createTeamsActivity({
+ type: 'messageReaction',
+ id: 'reaction-removed',
+ text: undefined,
+ entities: undefined,
+ replyToId: 'fast-message-1',
+ reactionsAdded: [],
+ reactionsRemoved: [{ type: 'heart' }],
+ }),
+ ),
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ ignored: 'reaction_removed',
+ });
+ expect(findFastMessageSessionMock).not.toHaveBeenCalled();
+ expect(queueFastReplyMock).not.toHaveBeenCalled();
});
it('does not claim a reaction suggestion when account mapping fails', async () => {
diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts
index 40ea8a244..188429044 100644
--- a/apps/api/src/handlers/teams/index.ts
+++ b/apps/api/src/handlers/teams/index.ts
@@ -29,9 +29,11 @@ import type { TeamsCommunicationProvider } from '@roomote/communication/teams-pr
import {
continueFastAgentSurfaceReply,
createTeamsCommunicationProviderFromRuntimeCredentials,
+ findFastAgentSessionForProviderMessage,
findFastAgentSessionForProviderReply,
findTeamsConversationRoute,
isFastAgentProviderMessage,
+ queueFastAgentSurfaceReply,
} from '@roomote/sdk/server';
import {
exchangeMicrosoftDelegatedGraphToken,
@@ -70,6 +72,7 @@ import { appendAttachmentTextsToPromptText } from '@roomote/cloud-agents';
import {
AUDIO_TRANSCRIPTION_MAX_SIZE_BYTES,
buildTeamsRoutingContext,
+ buildFastAgentReactionExternalInputQuestion,
enqueueTask,
formatAudioAttachmentWarning,
formatAudioTranscriptionResult,
@@ -78,6 +81,7 @@ import {
resolveAudioTranscriptionMimeType,
routeTask,
transcribeAudioAttachment,
+ type FastAgentReactionExternalInput,
type RoutingWorkspace,
} from '@roomote/cloud-agents/server';
@@ -1782,6 +1786,12 @@ teams.post('/', async (c) => {
}
const reactionTargetMessageId = activity.replyToId?.trim();
+ if (
+ (activity.reactionsAdded?.length ?? 0) === 0 &&
+ (activity.reactionsRemoved?.length ?? 0) > 0
+ ) {
+ return c.json({ ok: true, ignored: 'reaction_removed' });
+ }
const hasLikeReaction = (activity.reactionsAdded ?? []).some(
(reaction) => reaction.type === 'like',
);
@@ -1845,15 +1855,101 @@ teams.post('/', async (c) => {
}
}
- if (!configuration && !claimedSuggestionReaction) {
- return c.json({ ok: true, ignored: 'reaction_not_configured' });
- }
-
const targetMessageId = activity.replyToId?.trim();
if (!targetMessageId) {
return c.json({ ok: true, ignored: 'reaction_target_missing' });
}
+ if (!configuration && !claimedSuggestionReaction) {
+ const addedReactions = (activity.reactionsAdded ?? [])
+ .map((reaction) => reaction.type.trim().toLowerCase())
+ .filter(isTeamsNativeReactionType)
+ .map((name) => ({ name }));
+ if (addedReactions.length === 0) {
+ return c.json({ ok: true, ignored: 'reaction_not_configured' });
+ }
+
+ const metadata = getTeamsActivityCommunicationMetadata(activity);
+ const tenantId = metadata.teamsTenantId;
+ const fastChannelId = getTeamsBaseConversationId(
+ metadata.communicationChannelId,
+ );
+ const fastSession = tenantId
+ ? await findFastAgentSessionForProviderMessage({
+ provider: 'teams',
+ workspaceId: tenantId,
+ channelId: fastChannelId,
+ messageId: targetMessageId,
+ })
+ : null;
+ if (!fastSession) {
+ return c.json({ ok: true, ignored: 'reaction_not_configured' });
+ }
+
+ const mappedUserId = await findMappedTeamsUserId(activity);
+ if (!mappedUserId) {
+ await postTeamsAccountLinkPrompt({ activity, metadata });
+ return c.json({
+ ok: true,
+ queued: false,
+ reason: 'account_link_required',
+ });
+ }
+ if (fastSession.userId !== mappedUserId) {
+ return c.json({
+ ok: true,
+ queued: false,
+ reason: 'fast_session_user_mismatch',
+ });
+ }
+ if (fastSession.conversation.surface !== 'teams') {
+ return c.json({
+ ok: true,
+ queued: false,
+ reason: 'fast_session_surface_mismatch',
+ });
+ }
+
+ const eventId = activity.id ?? randomUUID();
+ const reactionInput: FastAgentReactionExternalInput = {
+ type: 'reaction_added',
+ provider: 'teams',
+ reactions: addedReactions,
+ reactor: {
+ externalUserId: activity.from?.id ?? mappedUserId,
+ ...(activity.from?.name?.trim()
+ ? { displayName: activity.from.name.trim() }
+ : {}),
+ },
+ message: {
+ workspaceId: tenantId!,
+ channelId: fastChannelId,
+ messageId: targetMessageId,
+ ...(fastSession.conversation.replyTarget.threadId
+ ? { threadId: fastSession.conversation.replyTarget.threadId }
+ : {}),
+ },
+ eventId,
+ };
+ const queued = await queueFastAgentSurfaceReply({
+ sessionId: fastSession.id,
+ userId: mappedUserId,
+ senderDisplayName: activity.from?.name?.trim() || null,
+ question: buildFastAgentReactionExternalInputQuestion(reactionInput),
+ currentMessageId: `teams-reaction:${eventId}`,
+ replyToMessageId: targetMessageId,
+ externalInput: reactionInput,
+ });
+ return c.json(
+ queued
+ ? { ok: true, fastReactionQueued: true }
+ : {
+ ok: true,
+ ignored: 'teams_fast_reaction_route_unavailable',
+ },
+ );
+ }
+
const mentionName = activity.recipient?.name?.trim() || PRODUCT_NAME;
const mentionText = `${mentionName}`;
activity = {
diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts
index 42026865c..a202dc1bd 100644
--- a/apps/api/src/handlers/telegram/__tests__/index.test.ts
+++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts
@@ -41,6 +41,8 @@ const {
telegramMappingsFindFirstMock,
appendAccountLinkHelpTextMock,
continueFastReplyMock,
+ queueFastReplyMock,
+ findFastMessageSessionMock,
findFastReplySessionMock,
getFastSessionMock,
isFastProviderMessageMock,
@@ -89,6 +91,8 @@ const {
telegramMappingsFindFirstMock: vi.fn(),
appendAccountLinkHelpTextMock: vi.fn(async (message: string) => message),
continueFastReplyMock: vi.fn(),
+ queueFastReplyMock: vi.fn(),
+ findFastMessageSessionMock: vi.fn(),
findFastReplySessionMock: vi.fn(),
getFastSessionMock: vi.fn(),
isFastProviderMessageMock: vi.fn(),
@@ -276,8 +280,10 @@ vi.mock('@roomote/sdk/server', () => ({
isTelegramLinkCode: (value: string) =>
/^link-[A-Za-z0-9_-]{16,}$/.test(value.trim()),
findTelegramPrimaryChatId: vi.fn(async () => null),
+ findFastAgentSessionForProviderMessage: findFastMessageSessionMock,
findFastAgentSessionForProviderReply: findFastReplySessionMock,
isFastAgentProviderMessage: isFastProviderMessageMock,
+ queueFastAgentSurfaceReply: queueFastReplyMock,
TELEGRAM_PRIMARY_CHAT_ENV_VAR_NAME: 'TELEGRAM_PRIMARY_CHAT_ID',
claimPendingPrReviewAction: vi.fn(async () => null),
claimPendingPrReviewActionsForThread: vi.fn(async () => []),
@@ -305,6 +311,10 @@ vi.mock('../../tasks/task-stop.js', () => ({
}));
vi.mock('@roomote/cloud-agents/server', () => ({
+ buildFastAgentReactionExternalInputQuestion: vi.fn(
+ (input: unknown) =>
+ `${JSON.stringify(input)}`,
+ ),
buildTelegramRoutingContext: buildTelegramRoutingContextMock,
classifyFollowUp: classifyFollowUpMock,
enqueueTask: enqueueTaskMock,
@@ -394,6 +404,8 @@ describe('Telegram webhook handler', () => {
telegramMappingsFindFirstMock.mockReset();
consumeLinkCodeMock.mockReset();
continueFastReplyMock.mockResolvedValue(true);
+ queueFastReplyMock.mockResolvedValue(true);
+ findFastMessageSessionMock.mockResolvedValue(null);
findFastReplySessionMock.mockResolvedValue(null);
getFastSessionMock.mockRejectedValue(new Error('Fast unavailable'));
isFastProviderMessageMock.mockResolvedValue(false);
@@ -481,6 +493,114 @@ describe('Telegram webhook handler', () => {
postMessageMock.mockResolvedValue({ messageId: 'telegram-response' });
});
+ it('queues a new reaction on the owner’s bound Fast message', async () => {
+ findFastMessageSessionMock.mockResolvedValue({
+ id: 'fast-session-1',
+ userId: 'mapped-user-1',
+ conversation: {
+ surface: 'telegram',
+ workspaceId: '222',
+ conversationId: '222:user:mapped-user-1',
+ replyTarget: { channelId: '222' },
+ },
+ });
+ mockTelegramLinkedSender('mapped-user-1');
+
+ const response = await postTelegramUpdate({
+ update_id: 124,
+ message_reaction: {
+ chat: { id: 222, type: 'private' },
+ message_id: 777,
+ date: 1_700_000_000,
+ user: {
+ id: 111,
+ first_name: 'Ada',
+ last_name: 'Lovelace',
+ username: 'ada',
+ },
+ old_reaction: [],
+ new_reaction: [{ type: 'emoji', emoji: '❤️' }],
+ },
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ fastReactionQueued: true,
+ });
+ expect(findFastMessageSessionMock).toHaveBeenCalledWith({
+ provider: 'telegram',
+ workspaceId: '222',
+ channelId: '222',
+ messageId: '777',
+ });
+ expect(queueFastReplyMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionId: 'fast-session-1',
+ userId: 'mapped-user-1',
+ currentMessageId: 'telegram-reaction:124',
+ replyToMessageId: '777',
+ externalInput: expect.objectContaining({
+ provider: 'telegram',
+ reactions: [{ name: '❤️' }],
+ }),
+ }),
+ );
+ });
+
+ it('rejects a reaction from a different Fast session owner', async () => {
+ findFastMessageSessionMock.mockResolvedValue({
+ id: 'fast-session-1',
+ userId: 'another-user',
+ conversation: {
+ surface: 'telegram',
+ workspaceId: '222',
+ conversationId: '222:user:another-user',
+ replyTarget: { channelId: '222' },
+ },
+ });
+ mockTelegramLinkedSender('mapped-user-1');
+
+ const response = await postTelegramUpdate({
+ update_id: 125,
+ message_reaction: {
+ chat: { id: 222, type: 'private' },
+ message_id: 777,
+ date: 1_700_000_000,
+ user: { id: 111, first_name: 'Ada' },
+ old_reaction: [],
+ new_reaction: [{ type: 'emoji', emoji: '🔥' }],
+ },
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ queued: false,
+ reason: 'fast_session_user_mismatch',
+ });
+ expect(queueFastReplyMock).not.toHaveBeenCalled();
+ });
+
+ it('ignores reaction removals without starting Fast or a suggestion', async () => {
+ const response = await postTelegramUpdate({
+ update_id: 126,
+ message_reaction: {
+ chat: { id: 222, type: 'private' },
+ message_id: 777,
+ date: 1_700_000_000,
+ user: { id: 111, first_name: 'Ada' },
+ old_reaction: [{ type: 'emoji', emoji: '👍' }],
+ new_reaction: [],
+ },
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ ignored: 'reaction_removed_or_unchanged',
+ });
+ expect(findFastMessageSessionMock).not.toHaveBeenCalled();
+ expect(queueFastReplyMock).not.toHaveBeenCalled();
+ });
+
it('remembers implicit Telegram topics so the task title can replace New Chat', async () => {
const response = await postTelegramUpdate(
createTelegramUpdate({
diff --git a/apps/api/src/handlers/telegram/index.ts b/apps/api/src/handlers/telegram/index.ts
index 54c3767b1..4e80259f0 100644
--- a/apps/api/src/handlers/telegram/index.ts
+++ b/apps/api/src/handlers/telegram/index.ts
@@ -17,6 +17,7 @@ import {
getTelegramUpdateCommunicationMetadata,
getTelegramUpdateMessage,
getTelegramUpdateMessageReaction,
+ getNewTelegramMessageReactions,
getTelegramNewTaskCommand,
isTelegramImplicitTopicCreatedMessage,
isTelegramPrivateChat,
@@ -38,12 +39,18 @@ import { retireTelegramPrReviewOffersBestEffort } from './pr-review-action.js';
import {
continueFastAgentSurfaceReply,
consumeTelegramLinkCode,
+ findFastAgentSessionForProviderMessage,
findFastAgentSessionForProviderReply,
isTelegramLinkCode,
isFastAgentProviderMessage,
+ queueFastAgentSurfaceReply,
restoreTelegramLinkCode,
} from '@roomote/sdk/server';
-import { getOrCreateFastAgentSession } from '@roomote/cloud-agents/server';
+import {
+ buildFastAgentReactionExternalInputQuestion,
+ getOrCreateFastAgentSession,
+ type FastAgentReactionExternalInput,
+} from '@roomote/cloud-agents/server';
import {
handleTelegramCallbackQuery,
@@ -139,15 +146,98 @@ telegram.post('/', async (c) => {
if (!claimedReaction) {
return c.json({ ok: true, duplicate: true });
}
- if (!isNewTelegramThumbsUpReaction(messageReaction)) {
- return c.json({ ok: true, ignored: 'unsupported_reaction' });
+ const addedReactions = getNewTelegramMessageReactions(messageReaction);
+ if (addedReactions.length === 0) {
+ return c.json({ ok: true, ignored: 'reaction_removed_or_unchanged' });
+ }
+
+ if (isNewTelegramThumbsUpReaction(messageReaction)) {
+ const handled = await handleTelegramSuggestionReaction(messageReaction);
+ if (handled) {
+ return c.json({ ok: true, suggestionStarted: true });
+ }
+ }
+
+ const chatId = String(messageReaction.chat.id);
+ const messageId = String(messageReaction.message_id);
+ const threadId = messageReaction.message_thread_id
+ ? String(messageReaction.message_thread_id)
+ : undefined;
+ const fastSession = await findFastAgentSessionForProviderMessage({
+ provider: 'telegram',
+ workspaceId: chatId,
+ channelId: chatId,
+ ...(threadId ? { threadId } : {}),
+ messageId,
+ });
+ if (!fastSession) {
+ return c.json({ ok: true, ignored: 'reaction_target_not_fast' });
+ }
+ if (!messageReaction.user) {
+ return c.json({ ok: true, ignored: 'reaction_user_missing' });
}
- const handled = await handleTelegramSuggestionReaction(messageReaction);
+ const senderUserId = await resolveTelegramSenderUserId(
+ String(messageReaction.user.id),
+ );
+ if (!senderUserId) {
+ await postTelegramMessageBestEffort({
+ chatId,
+ ...(threadId ? { threadId } : {}),
+ replyToMessageId: messageId,
+ text: 'Link your Roomote account to respond to Roomote from Telegram.',
+ });
+ return c.json({
+ ok: true,
+ queued: false,
+ reason: 'telegram_reactor_not_linked',
+ });
+ }
+ if (fastSession.userId !== senderUserId) {
+ return c.json({
+ ok: true,
+ queued: false,
+ reason: 'fast_session_user_mismatch',
+ });
+ }
+
+ const senderDisplayName =
+ [messageReaction.user.first_name, messageReaction.user.last_name]
+ .filter(Boolean)
+ .join(' ')
+ .trim() ||
+ messageReaction.user.username?.trim() ||
+ null;
+ const eventId = String(update.update_id);
+ const reactionInput: FastAgentReactionExternalInput = {
+ type: 'reaction_added',
+ provider: 'telegram',
+ reactions: addedReactions,
+ reactor: {
+ externalUserId: String(messageReaction.user.id),
+ ...(senderDisplayName ? { displayName: senderDisplayName } : {}),
+ },
+ message: {
+ workspaceId: chatId,
+ channelId: chatId,
+ messageId,
+ ...(threadId ? { threadId } : {}),
+ },
+ eventId,
+ };
+ const queued = await queueFastAgentSurfaceReply({
+ sessionId: fastSession.id,
+ userId: senderUserId,
+ senderDisplayName,
+ question: buildFastAgentReactionExternalInputQuestion(reactionInput),
+ currentMessageId: `telegram-reaction:${eventId}`,
+ replyToMessageId: messageId,
+ externalInput: reactionInput,
+ });
return c.json(
- handled
- ? { ok: true, suggestionStarted: true }
- : { ok: true, ignored: 'reaction_target_not_suggestion' },
+ queued
+ ? { ok: true, fastReactionQueued: true }
+ : { ok: true, ignored: 'telegram_fast_reaction_route_unavailable' },
);
}
diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx
index 233661c11..951be1dd5 100644
--- a/apps/docs/automations.mdx
+++ b/apps/docs/automations.mdx
@@ -220,6 +220,11 @@ Provider support differs slightly:
`sad`, and `angry` reactions; choose an equivalent configured emoji such as
`:thumbsup:` for Like or `:heart:` for Heart.
+Telegram reactions on Roomote Fast replies are supported, but Telegram is not
+available for **Call Roomote via emoji** on arbitrary messages. Its Bot API
+reaction updates are handled only for user-attributed reactions on Roomote
+replies and suggested-task cards.
+
## Channel automations
The channel section starts with **Auto-respond to channels**.
diff --git a/apps/docs/communications.mdx b/apps/docs/communications.mdx
index e854be4fa..106be5fb5 100644
--- a/apps/docs/communications.mdx
+++ b/apps/docs/communications.mdx
@@ -58,6 +58,26 @@ also needs an environment that can run the suggestion's target repository.
Configured **Call Roomote via emoji** automations are separate from these
suggestion reactions.
+## React to Roomote replies
+
+When you react to a Roomote reply in an active Fast conversation, Roomote can
+use that reaction as context. The linked user who owns the Fast conversation
+must add the reaction. Roomote posts a text reply when the reaction warrants a
+response and otherwise stays silent; it does not react to the reaction event
+itself.
+
+| Provider | Fast reply reactions | Provider limits |
+| --- | --- | --- |
+| Slack | Supported | Slack sends standard and workspace custom `reaction_added` events. |
+| Discord | Supported | Discord Gateway sends standard and server custom reaction-add events, but the event does not include the reacted-to message text. |
+| Microsoft Teams | Supported | Bot Framework sends native `messageReaction` activities only for messages posted by Roomote. Reaction removals are ignored. |
+| Telegram | Supported | Roomote uses user-attributed `message_reaction` updates. Anonymous aggregate `message_reaction_count` updates are not supported because they do not identify the reacting user. |
+
+These reaction paths apply only to Roomote replies that were recorded for the
+current Fast conversation. Reactions on older or unrelated messages do not gain
+access to that conversation. Suggested-task reactions keep their dedicated
+launch behavior described above.
+
Use the same stable public URL for every provider app setting that requires a
callback or webhook. If you change that URL, update those provider settings and
restart Roomote with the matching deployment URL. Discord receives messages
diff --git a/apps/docs/providers/communications/telegram.mdx b/apps/docs/providers/communications/telegram.mdx
index 46ad19a17..8aa5b90a7 100644
--- a/apps/docs/providers/communications/telegram.mdx
+++ b/apps/docs/providers/communications/telegram.mdx
@@ -67,7 +67,11 @@ When you save Telegram credentials in Roomote, the webhook is registered
automatically at `/api/webhooks/telegram` with the managed secret
token and `allowed_updates` including `message`, `callback_query`, and
`message_reaction`. Roomote uses newly added 👍 reactions on suggested-task
-messages to launch the selected task.
+messages to launch the selected task and user-attributed reactions on Roomote
+Fast replies as conversation input. Roomote does not request
+`message_reaction_count`: those aggregate updates do not identify the reacting
+user, so they cannot satisfy Roomote's account and conversation ownership
+checks.
If the connection check reports a mismatch, delivery error, or stale update
configuration, use **Repair** in Telegram settings to re-register it.
diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts
index 91667e4b0..1eed4bd53 100644
--- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts
+++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts
@@ -504,7 +504,7 @@ describe('buildFastAgentSystemPrompt', () => {
expect(prompt).not.toContain('Do not call "ignore_event"');
expect(prompt).toContain('Do not use the reaction tool');
expect(prompt).toContain(
- 'an inbound Slack reaction event is not itself a reactable message surface',
+ 'an inbound emoji-reaction event is not itself a reactable message surface',
);
expect(prompt).toContain(
'If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above',
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 f32fc51da..5048f6729 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
@@ -2485,6 +2485,40 @@ describe('answerFastAgentQuestion native OpenCode tools', () => {
expect(adapter.postReply).not.toHaveBeenCalled();
});
+ it('rejects reaction side effects during platform events', async () => {
+ let reactionResult: unknown;
+ mocks.generateText.mockImplementation(
+ async (_params, _session, options) => {
+ await options.onSessionReady('opencode-session-1');
+ reactionResult = await invokeTool(nativeToolNames.sendChatReaction, {
+ name: 'thumbsup',
+ purpose: 'closeout',
+ });
+ await invokeTool(nativeToolNames.ignoreEvent, {
+ reason: 'no response needed',
+ });
+ return '';
+ },
+ );
+ const adapter = callbacks();
+
+ await answerFastAgentQuestion({
+ ...baseParams,
+ currentMessageId: 'slack-reaction:1710000000.000100',
+ turnSource: 'platform_event',
+ platformEventKind: 'external_input',
+ platformEventVisibility: 'optional',
+ adapter,
+ });
+
+ expect(reactionResult).toEqual({
+ success: false,
+ error:
+ 'Emoji reactions are unavailable during platform events. Use send_chat_reply or ignore_event instead.',
+ });
+ expect(adapter.postReaction).not.toHaveBeenCalled();
+ });
+
it('posts a closeout when a visibility-required event tries to ignore itself', async () => {
mocks.generateText.mockImplementation(
async (_params, _session, options) => {
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts
index dac627c94..1928e277f 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts
@@ -27,6 +27,27 @@ export type FastAgentPlatformEventKind =
| 'automation'
| 'external_input';
+export type FastAgentReactionExternalInput = {
+ type: 'reaction_added';
+ provider: 'slack' | 'discord' | 'teams' | 'telegram';
+ reactions: Array<{ name: string; id?: string }>;
+ reactor: { externalUserId: string; displayName?: string };
+ message: {
+ workspaceId: string;
+ channelId: string;
+ messageId: string;
+ threadId?: string;
+ text?: string;
+ };
+ eventId: string;
+};
+
+export function buildFastAgentReactionExternalInputQuestion(
+ input: FastAgentReactionExternalInput,
+): string {
+ return `${JSON.stringify(input)}`;
+}
+
export type FastAgentSuggestedTask = {
title: string;
brief: string;
diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
index 64e91011e..caa08ef9a 100644
--- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
+++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts
@@ -257,7 +257,7 @@ ${
: '- No failed-start retry tool is available for this event. Report or ignore it without retrying.'
}
- Launching creates a separate delegated task; it does not retry the task associated with this event.
-- Do not use the reaction tool because a platform event has no incoming chat message to react to. In particular, an inbound Slack reaction event is not itself a reactable message surface. If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above.
+- Do not use the reaction tool because a platform event has no incoming chat message to react to. In particular, an inbound emoji-reaction event is not itself a reactable message surface. If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above.
${
platformEventKind === 'automation'
? `- Execute the automation prompt now. Use integrations directly when sufficient, and launch a task only when repository or workspace execution is actually required. The configured model is a delegated-task default, not the Fast inference model.
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 8489df7a2..ff3c5456e 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
@@ -1577,6 +1577,13 @@ export async function answerFastAgentQuestion({
case FAST_AGENT_NATIVE_TOOL_NAMES.sendChatReaction: {
const args = chatReactionArgsSchema.parse(call.args);
+ if (platformEvent) {
+ return {
+ success: false,
+ error:
+ 'Emoji reactions are unavailable during platform events. Use send_chat_reply or ignore_event instead.',
+ };
+ }
if (!adapter.postReaction) {
return {
success: false,
diff --git a/packages/communication/src/__tests__/teams-activity.test.ts b/packages/communication/src/__tests__/teams-activity.test.ts
index 11fd4b8d1..c84d57da5 100644
--- a/packages/communication/src/__tests__/teams-activity.test.ts
+++ b/packages/communication/src/__tests__/teams-activity.test.ts
@@ -16,6 +16,23 @@ import {
} from '../teams-activity';
describe('Teams activity helpers', () => {
+ it('parses added and removed message reactions', () => {
+ const parsed = parseTeamsActivity({
+ type: 'messageReaction',
+ id: 'reaction-1',
+ conversation: { id: '19:conversation@thread.v2' },
+ replyToId: 'activity-root',
+ reactionsAdded: [{ type: 'heart' }],
+ reactionsRemoved: [{ type: 'like' }],
+ });
+
+ expect(parsed.success).toBe(true);
+ expect(parsed.data).toMatchObject({
+ reactionsAdded: [{ type: 'heart' }],
+ reactionsRemoved: [{ type: 'like' }],
+ });
+ });
+
it('parses Teams message activities into queued communication messages', () => {
const parsed = parseTeamsActivity({
type: 'message',
diff --git a/packages/communication/src/__tests__/telegram-update.test.ts b/packages/communication/src/__tests__/telegram-update.test.ts
index 4bb6bf0ba..12f30178f 100644
--- a/packages/communication/src/__tests__/telegram-update.test.ts
+++ b/packages/communication/src/__tests__/telegram-update.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
+ getNewTelegramMessageReactions,
getTelegramNewTaskCommand,
getTelegramUpdateCallbackQuery,
getTelegramUpdateCommunicationMetadata,
@@ -92,6 +93,20 @@ describe('Telegram update helpers', () => {
old_reaction: [{ type: 'emoji', emoji: '👍' }],
}),
).toBe(false);
+ expect(reaction && getNewTelegramMessageReactions(reaction)).toEqual([
+ { name: '👍' },
+ ]);
+ expect(
+ reaction &&
+ getNewTelegramMessageReactions({
+ ...reaction,
+ old_reaction: [{ type: 'emoji', emoji: '👍' }],
+ new_reaction: [
+ { type: 'emoji', emoji: '👍' },
+ { type: 'custom_emoji', custom_emoji_id: 'custom-1' },
+ ],
+ }),
+ ).toEqual([{ name: 'custom_emoji:custom-1', id: 'custom-1' }]);
});
it('parses Telegram messages into queued communication messages', () => {
diff --git a/packages/communication/src/teams-activity.ts b/packages/communication/src/teams-activity.ts
index b0f93555d..3cbdc81be 100644
--- a/packages/communication/src/teams-activity.ts
+++ b/packages/communication/src/teams-activity.ts
@@ -80,6 +80,15 @@ export const teamsActivitySchema = z
.passthrough(),
)
.optional(),
+ reactionsRemoved: z
+ .array(
+ z
+ .object({
+ type: z.string(),
+ })
+ .passthrough(),
+ )
+ .optional(),
attachments: z.array(z.unknown()).optional(),
})
.passthrough();
diff --git a/packages/communication/src/telegram-update.ts b/packages/communication/src/telegram-update.ts
index 19f2e0965..26aedb1f9 100644
--- a/packages/communication/src/telegram-update.ts
+++ b/packages/communication/src/telegram-update.ts
@@ -222,6 +222,27 @@ export function isNewTelegramThumbsUpReaction(
);
}
+export function getNewTelegramMessageReactions(
+ reaction: TelegramMessageReaction,
+): Array<{ name: string; id?: string }> {
+ const reactionKey = (item: TelegramMessageReaction['new_reaction'][number]) =>
+ `${item.type}:${item.emoji ?? ''}:${item.custom_emoji_id ?? ''}`;
+ const oldReactionKeys = new Set(reaction.old_reaction.map(reactionKey));
+
+ return reaction.new_reaction
+ .filter((item) => !oldReactionKeys.has(reactionKey(item)))
+ .map((item) =>
+ item.type === 'emoji' && item.emoji
+ ? { name: item.emoji }
+ : item.type === 'custom_emoji' && item.custom_emoji_id
+ ? {
+ name: `custom_emoji:${item.custom_emoji_id}`,
+ id: item.custom_emoji_id,
+ }
+ : { name: item.type },
+ );
+}
+
export function getTelegramChatId(message: TelegramMessage): string {
return String(message.chat.id);
}
diff --git a/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts b/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts
index 12967713d..ac18be69a 100644
--- a/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts
+++ b/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { db, fastAgentConversations, userFactory } from '@roomote/db/server';
import {
+ findFastAgentSessionForProviderMessage,
findFastAgentSessionForProviderReply,
isFastAgentProviderMessage,
recordFastAgentProviderMessage,
@@ -50,14 +51,66 @@ describe('Fast provider message bindings', () => {
});
await expect(
- findFastAgentSessionForProviderReply({
+ findFastAgentSessionForProviderMessage({
provider: 'slack',
workspaceId: `team:${suffix}`,
channelId: `channel:${suffix}`,
- replyToMessageId: `message:${suffix}`,
+ threadId: `thread:${suffix}`,
+ messageId: `message:${suffix}`,
+ userId: user.id,
+ }),
+ ).resolves.toMatchObject({ id: conversation.id, userId: user.id });
+ });
+
+ it('requires exact provider-message route and ownership for reactions', async () => {
+ const suffix = crypto.randomUUID();
+ const { user, conversation } = await createFastConversation({
+ surface: 'telegram',
+ workspaceId: `chat:${suffix}`,
+ conversationId: `topic:${suffix}`,
+ channelId: `chat:${suffix}`,
+ threadId: `topic:${suffix}`,
+ });
+ const otherUser = await userFactory.create();
+ await recordFastAgentProviderMessage({
+ sessionId: conversation.id,
+ provider: 'telegram',
+ workspaceId: `chat:${suffix}`,
+ channelId: `chat:${suffix}`,
+ threadId: `topic:${suffix}`,
+ messageId: `message:${suffix}`,
+ });
+
+ await expect(
+ findFastAgentSessionForProviderMessage({
+ provider: 'telegram',
+ workspaceId: `chat:${suffix}`,
+ channelId: `chat:${suffix}`,
+ threadId: `topic:${suffix}`,
+ messageId: `message:${suffix}`,
userId: user.id,
}),
).resolves.toMatchObject({ id: conversation.id, userId: user.id });
+ await expect(
+ findFastAgentSessionForProviderMessage({
+ provider: 'telegram',
+ workspaceId: `chat:${suffix}`,
+ channelId: `chat:${suffix}`,
+ threadId: `other-topic:${suffix}`,
+ messageId: `message:${suffix}`,
+ userId: user.id,
+ }),
+ ).resolves.toBeNull();
+ await expect(
+ findFastAgentSessionForProviderMessage({
+ provider: 'telegram',
+ workspaceId: `chat:${suffix}`,
+ channelId: `chat:${suffix}`,
+ threadId: `topic:${suffix}`,
+ messageId: `message:${suffix}`,
+ userId: otherUser.id,
+ }),
+ ).resolves.toBeNull();
});
it('resolves a Discord DM reply to the bound Fast session', async () => {
diff --git a/packages/sdk/src/server/lib/fast-agent-provider-message.ts b/packages/sdk/src/server/lib/fast-agent-provider-message.ts
index 20a236383..28c0fe9b5 100644
--- a/packages/sdk/src/server/lib/fast-agent-provider-message.ts
+++ b/packages/sdk/src/server/lib/fast-agent-provider-message.ts
@@ -153,6 +153,38 @@ export async function findFastAgentSessionForProviderReply(
: null;
}
+export async function findFastAgentSessionForProviderMessage(
+ input: ProviderRoute & { messageId: string; userId?: string },
+): Promise {
+ const binding = await db.query.fastAgentProviderMessages.findFirst({
+ where: and(
+ eq(fastAgentProviderMessages.provider, input.provider),
+ eq(fastAgentProviderMessages.workspaceId, input.workspaceId),
+ eq(fastAgentProviderMessages.channelId, input.channelId),
+ eq(fastAgentProviderMessages.messageId, input.messageId),
+ ),
+ columns: { conversationId: true, threadId: true },
+ });
+ if (
+ !binding ||
+ (input.threadId !== undefined && binding.threadId !== input.threadId)
+ ) {
+ return null;
+ }
+
+ const session = await fastAgentConversationRepository.findById({
+ id: binding.conversationId,
+ });
+ return session &&
+ (!input.userId || session.userId === input.userId) &&
+ matchesProviderRoute(session, {
+ ...input,
+ ...(binding.threadId ? { threadId: binding.threadId } : {}),
+ })
+ ? session
+ : null;
+}
+
export async function isFastAgentProviderMessage(input: {
provider: FastAgentReplyProvider;
messageId: string;
diff --git a/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts b/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts
index 8a33de86f..5a7d0979a 100644
--- a/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts
+++ b/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts
@@ -6,6 +6,22 @@ const mocks = vi.hoisted(() => ({
telegramPostMessage: vi.fn(),
telegramEditMessage: vi.fn(),
findTeamsConversationRoute: vi.fn(),
+ slackPostThreadMessage: vi.fn(),
+ slackUpdateMessage: vi.fn(),
+}));
+
+vi.mock('@roomote/slack', () => ({
+ buildSlackThreadReplyFooterBlock: vi.fn(() => ({ type: 'context' })),
+ createFastAgentSlackLiveTaskLauncher: vi.fn(() => vi.fn()),
+ getSlackThreadReplyFooterMessageTs: vi.fn(async () => null),
+ postSlackThreadMessageWithFooterText: mocks.slackPostThreadMessage,
+ withSlackThreadReplyFooterLock: vi.fn(
+ async ({ fn }: { fn: () => Promise }) => fn(),
+ ),
+ ROOMOTE_THREAD_REPLY_QUOTE_BLOCK_ID: 'quote',
+ SlackNotifier: vi.fn(function () {
+ return { updateMessage: mocks.slackUpdateMessage };
+ }),
}));
vi.mock('./teams-communication', () => ({
@@ -29,6 +45,7 @@ import {
fastAgentConversations,
fastAgentProviderMessages,
fastAgentMessages,
+ slackInstallations,
userFactory,
} from '@roomote/db/server';
@@ -80,6 +97,8 @@ describe('buildFastAgentSurfaceReplyDelivery', () => {
serviceUrl: 'https://smba.example.com/amer/',
workspaceId: 'tenant-1',
});
+ mocks.slackPostThreadMessage.mockResolvedValue('slack-message-1');
+ mocks.slackUpdateMessage.mockResolvedValue(true);
});
it('serves web sessions with a transcript-only adapter', async () => {
@@ -178,6 +197,57 @@ describe('buildFastAgentSurfaceReplyDelivery', () => {
).resolves.toBeNull();
});
+ it('binds Slack surface replies and replacements to the Fast session', async () => {
+ const user = await userFactory.create();
+ const conversation = await createConversation({
+ userId: user.id,
+ surface: 'slack',
+ replyTarget: { channelId: 'C456', threadId: '1700000000.000200' },
+ });
+ await db.insert(slackInstallations).values({
+ teamId: conversation.workspaceId,
+ teamName: 'Test workspace',
+ appId: 'A123',
+ botUserId: 'B123',
+ botAccessToken: 'xoxb-test',
+ scopes: { bot: ['chat:write'] },
+ installedByUserId: user.id,
+ isActive: true,
+ });
+
+ const delivery = await buildFastAgentSurfaceReplyDelivery({
+ sessionId: conversation.id,
+ userId: user.id,
+ senderDisplayName: 'Matt',
+ question: 'Follow up',
+ });
+ const handle = await delivery!.adapter.postReply({
+ purpose: 'closeout',
+ message: 'First reply',
+ });
+ await delivery!.adapter.replaceReply!(handle!, {
+ purpose: 'closeout',
+ message: 'Updated reply',
+ });
+
+ await expect(
+ db.query.fastAgentProviderMessages.findFirst({
+ where: and(
+ eq(fastAgentProviderMessages.provider, 'slack'),
+ eq(fastAgentProviderMessages.conversationId, conversation.id),
+ eq(fastAgentProviderMessages.messageId, 'slack-message-1'),
+ ),
+ }),
+ ).resolves.toMatchObject({
+ workspaceId: conversation.workspaceId,
+ channelId: 'C456',
+ threadId: '1700000000.000200',
+ });
+ expect(mocks.slackUpdateMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ ts: 'slack-message-1' }),
+ );
+ });
+
it('returns null for an unknown session', async () => {
const user = await userFactory.create();
await expect(
@@ -276,4 +346,51 @@ describe('buildFastAgentSurfaceReplyDelivery', () => {
);
},
);
+
+ it('keeps a Telegram reaction event id separate from its reply target', async () => {
+ const user = await userFactory.create();
+ const [conversation] = await db
+ .insert(fastAgentConversations)
+ .values({
+ userId: user.id,
+ surface: 'telegram',
+ workspaceId: 'telegram-chat-reaction',
+ conversationId: `telegram-reaction-${Date.now()}`,
+ currentReplyChannelId: 'telegram-chat-reaction',
+ })
+ .returning();
+
+ const delivery = await buildFastAgentSurfaceReplyDelivery({
+ sessionId: conversation!.id,
+ userId: user.id,
+ senderDisplayName: 'Matt',
+ question: '{}',
+ currentMessageId: 'telegram-reaction:123',
+ replyToMessageId: '777',
+ externalInput: {
+ type: 'reaction_added',
+ provider: 'telegram',
+ reactions: [{ name: '👍' }],
+ reactor: { externalUserId: '111', displayName: 'Matt' },
+ message: {
+ workspaceId: 'telegram-chat-reaction',
+ channelId: 'telegram-chat-reaction',
+ messageId: '777',
+ },
+ eventId: '123',
+ },
+ });
+
+ await delivery!.adapter.postReply({
+ purpose: 'closeout',
+ message: 'Thanks for confirming.',
+ });
+
+ expect(mocks.telegramPostMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ replyToMessageId: '777',
+ text: expect.not.stringContaining('external_input'),
+ }),
+ );
+ });
});
diff --git a/packages/sdk/src/server/lib/fast-agent-surface-reply.ts b/packages/sdk/src/server/lib/fast-agent-surface-reply.ts
index dabba5169..0df419b59 100644
--- a/packages/sdk/src/server/lib/fast-agent-surface-reply.ts
+++ b/packages/sdk/src/server/lib/fast-agent-surface-reply.ts
@@ -6,6 +6,7 @@ import {
getActiveFastAgentTasks,
resolveApiBaseUrl,
type FastAgentConversation,
+ type FastAgentReactionExternalInput,
type FastAgentTurnAdapter,
} from '@roomote/cloud-agents/server';
import {
@@ -114,6 +115,17 @@ export type FastAgentSurfaceReplyDelivery = {
>;
};
+type FastAgentSurfaceReplyParams = {
+ sessionId: string;
+ userId: string;
+ senderDisplayName: string | null;
+ question: string;
+ currentMessageId: string;
+ replyToMessageId?: string;
+ images?: string[];
+ externalInput?: FastAgentReactionExternalInput;
+};
+
export async function canUserAccessFastAgentSession(params: {
sessionId: string;
userId: string;
@@ -151,6 +163,8 @@ export async function buildFastAgentSurfaceReplyDelivery(params: {
senderDisplayName: string | null;
question: string;
currentMessageId?: string;
+ replyToMessageId?: string;
+ externalInput?: FastAgentReactionExternalInput;
}): Promise {
const session = await fastAgentConversationRepository.findById({
id: params.sessionId,
@@ -202,10 +216,12 @@ export async function buildFastAgentSurfaceReplyDelivery(params: {
}
const slack = new SlackNotifier(installation.botAccessToken);
- let pendingQuote = buildSlackReplyQuote({
- senderDisplayName: params.senderDisplayName,
- text: params.question,
- });
+ let pendingQuote = params.externalInput
+ ? null
+ : buildSlackReplyQuote({
+ senderDisplayName: params.senderDisplayName,
+ text: params.question,
+ });
return {
conversation,
@@ -249,6 +265,11 @@ export async function buildFastAgentSurfaceReplyDelivery(params: {
if (!messageTs) {
throw new Error('Slack did not return a Fast reply timestamp.');
}
+ await recordFastAgentConversationMessageBestEffort({
+ sessionId: session.id,
+ conversation,
+ messageId: messageTs,
+ });
return { messageId: messageTs };
},
replaceReply: async (handle, { message }) => {
@@ -289,6 +310,11 @@ export async function buildFastAgentSurfaceReplyDelivery(params: {
if (!updated) {
throw new Error('Slack did not update the Fast reply.');
}
+ await recordFastAgentConversationMessageBestEffort({
+ sessionId: session.id,
+ conversation,
+ messageId: handle.messageId,
+ });
return handle;
},
},
@@ -302,10 +328,12 @@ export async function buildFastAgentSurfaceReplyDelivery(params: {
return null;
}
- let pendingQuote = buildDiscordReplyQuote({
- senderDisplayName: params.senderDisplayName,
- text: params.question,
- });
+ let pendingQuote = params.externalInput
+ ? null
+ : buildDiscordReplyQuote({
+ senderDisplayName: params.senderDisplayName,
+ text: params.question,
+ });
return {
conversation,
@@ -437,6 +465,7 @@ export async function buildFastAgentSurfaceReplyDelivery(params: {
if (!provider) {
return null;
}
+ const replyToMessageId = params.replyToMessageId ?? params.currentMessageId;
return {
conversation,
adapter: {
@@ -450,9 +479,7 @@ export async function buildFastAgentSurfaceReplyDelivery(params: {
...(conversation.replyTarget.threadId
? { threadId: conversation.replyTarget.threadId }
: {}),
- ...(params.currentMessageId
- ? { replyToMessageId: params.currentMessageId }
- : {}),
+ ...(replyToMessageId ? { replyToMessageId } : {}),
text: `${message}\n\n${buildFastSessionReplyFooterText({ provider: 'telegram', sessionId: session.id, ...footerContext })}`,
textFormat: 'markdown',
});
@@ -484,14 +511,9 @@ export async function buildFastAgentSurfaceReplyDelivery(params: {
return null;
}
-export async function continueFastAgentSurfaceReply(params: {
- sessionId: string;
- userId: string;
- senderDisplayName: string | null;
- question: string;
- currentMessageId: string;
- images?: string[];
-}): Promise {
+export async function continueFastAgentSurfaceReply(
+ params: FastAgentSurfaceReplyParams,
+): Promise {
const delivery = await buildFastAgentSurfaceReplyDelivery(params);
if (!delivery) {
return false;
@@ -500,15 +522,11 @@ export async function continueFastAgentSurfaceReply(params: {
return runFastAgentSurfaceReply({ ...params, delivery });
}
-async function runFastAgentSurfaceReply(params: {
- sessionId: string;
- userId: string;
- senderDisplayName: string | null;
- question: string;
- currentMessageId: string;
- images?: string[];
- delivery: FastAgentSurfaceReplyDelivery;
-}): Promise {
+async function runFastAgentSurfaceReply(
+ params: FastAgentSurfaceReplyParams & {
+ delivery: FastAgentSurfaceReplyDelivery;
+ },
+): Promise {
const { delivery } = params;
const release = await acquireFastAgentTurnLock({
@@ -520,6 +538,9 @@ async function runFastAgentSurfaceReply(params: {
const apiBaseUrl = resolveApiBaseUrl() ?? undefined;
try {
+ const activeTasks = params.externalInput
+ ? await getActiveFastAgentTasks(params.sessionId)
+ : undefined;
await answerFastAgentQuestion({
question: params.question,
images: params.images,
@@ -529,6 +550,18 @@ async function runFastAgentSurfaceReply(params: {
currentMessageId: params.currentMessageId,
signal: release.signal,
senderDisplayName: params.senderDisplayName ?? undefined,
+ ...(activeTasks ? { activeTasks } : {}),
+ ...(params.externalInput
+ ? {
+ senderExternalId: params.externalInput.reactor.externalUserId,
+ turnSource: 'platform_event' as const,
+ platformEventKind: 'external_input' as const,
+ platformEventVisibility: 'optional' as const,
+ platformEventTranscriptPayload: {
+ externalInput: params.externalInput,
+ },
+ }
+ : {}),
adapter: {
resolveMcpServerConfigs: () =>
resolveUserMcpServerConfigs({
@@ -545,14 +578,9 @@ async function runFastAgentSurfaceReply(params: {
}
}
-export async function queueFastAgentSurfaceReply(params: {
- sessionId: string;
- userId: string;
- senderDisplayName: string | null;
- question: string;
- currentMessageId: string;
- images?: string[];
-}): Promise {
+export async function queueFastAgentSurfaceReply(
+ params: FastAgentSurfaceReplyParams,
+): Promise {
const delivery = await buildFastAgentSurfaceReplyDelivery(params);
if (!delivery) return false;