diff --git a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts index 4ac587ff..b6c102d7 100644 --- a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts @@ -68,11 +68,24 @@ describe('Discord component callbacks', () => { status: 'running', sandboxServerUrl: null, actingUserId: 'user-1', + payload: { + communicationProvider: 'discord', + communicationChannelId: 'channel-1', + communicationThreadId: 'thread-1', + discordReactionChannelId: 'channel-1', + discordReactionMessageId: 'origin-1', + discordTaskThread: true, + }, task: { initiatorUserId: 'owner-user' }, }; mocks.findRun.mockResolvedValue(run); mocks.stopTaskRun.mockResolvedValue({ success: true, statusCode: 200 }); - const provider = {} as never; + const addReaction = vi.fn().mockResolvedValue(undefined); + const provider = { + addReaction, + } as unknown as { + addReaction: typeof addReaction; + }; const interaction = { id: 'interaction-1', application_id: 'app-1', @@ -87,7 +100,7 @@ describe('Discord component callbacks', () => { }; const result = await handleDiscordComponentInteraction({ - provider, + provider: provider as never, applicationId: 'app-1', interaction, interactionDeferred: true, @@ -111,6 +124,11 @@ describe('Discord component callbacks', () => { terminate: true, cancelledBy: { name: 'Matt', source: 'discord' }, }); + expect(addReaction).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'origin-1', + name: 'x', + }); expect(mocks.reply).toHaveBeenCalledWith( expect.objectContaining({ applicationId: 'app-1', diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index aeff7a92..d41341a6 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -364,6 +364,11 @@ describe('Discord Gateway event handler', () => { eventId: 'message-1', token: 'claim-token', }); + expect(mocks.addReaction).toHaveBeenCalledWith({ + channelId: 'dm-1', + messageId: 'message-1', + name: '👀', + }); expect(mocks.startNewTask).toHaveBeenCalledWith( expect.objectContaining({ requesterDiscordUserId: 'discord-user-1', @@ -384,6 +389,26 @@ describe('Discord Gateway event handler', () => { ); }); + it('still launches when the initial eyes reaction fails', async () => { + mocks.addReaction.mockRejectedValueOnce(new Error('rate limited')); + + const response = await postEvent(envelope(message())); + + expect(response.status).toBe(200); + expect(mocks.addReaction).toHaveBeenCalledWith({ + channelId: 'dm-1', + messageId: 'message-1', + name: '👀', + }); + expect(mocks.startNewTask).toHaveBeenCalledTimes(1); + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + ok: true, + status: 'started', + }), + ); + }); + it('treats an attachment-only DM as a task entry and passes safe image data', async () => { const attachment = { id: 'attachment-1', @@ -644,6 +669,9 @@ describe('Discord Gateway event handler', () => { expect(response.status).toBe(200); expect(mocks.findActiveRun).not.toHaveBeenCalled(); expect(mocks.queueMessage).not.toHaveBeenCalled(); + // Interaction ids are not reaction targets; eyes belong to message launches + // or the post-launch acknowledgement path. + expect(mocks.addReaction).not.toHaveBeenCalled(); expect(mocks.startNewTask).toHaveBeenCalledWith( expect.objectContaining({ queuedMessage: expect.objectContaining({ diff --git a/apps/api/src/handlers/discord/__tests__/task-launch.test.ts b/apps/api/src/handlers/discord/__tests__/task-launch.test.ts index da896484..7056e2d6 100644 --- a/apps/api/src/handlers/discord/__tests__/task-launch.test.ts +++ b/apps/api/src/handlers/discord/__tests__/task-launch.test.ts @@ -4,6 +4,9 @@ const mocks = vi.hoisted(() => ({ redisGet: vi.fn(), redisSet: vi.fn(), redisDel: vi.fn(), + dbUpdate: vi.fn(), + dbSet: vi.fn(), + dbWhere: vi.fn(), })); vi.mock('@roomote/cloud-agents/server', () => ({ @@ -19,6 +22,32 @@ vi.mock('@roomote/redis', () => ({ }), })); +vi.mock('@roomote/db/server', () => ({ + db: { + update: (...args: unknown[]) => { + mocks.dbUpdate(...args); + return { + set: (...setArgs: unknown[]) => { + mocks.dbSet(...setArgs); + return { + where: (...whereArgs: unknown[]) => { + mocks.dbWhere(...whereArgs); + return Promise.resolve(); + }, + }; + }, + }; + }, + }, + environments: { id: 'id' }, + taskRuns: { id: 'id', payload: 'payload' }, + eq: (...values: unknown[]) => values, + sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings, + values, + }), +})); + import { DiscordApiError } from '@roomote/communication/discord-provider'; import { launchDiscordTask } from '../task-launch.js'; @@ -222,6 +251,8 @@ describe('launchDiscordTask', () => { communicationThreadId: 'message-1', communicationMessageId: 'message-1', discordTaskThread: true, + discordReactionChannelId: 'channel-1', + discordReactionMessageId: 'message-1', }), }, }), @@ -229,6 +260,65 @@ describe('launchDiscordTask', () => { ); }); + it('persists the acknowledgement message as the reaction target for interaction launches', async () => { + const provider = { + reserveTaskThread: vi.fn(), + createThreadFromMessage: vi.fn(), + completeTaskThread: vi.fn(), + postMessage: vi.fn().mockResolvedValue({ messageId: 'ack-dm-1' }), + addReaction: vi.fn().mockResolvedValue(undefined), + }; + + await launchDiscordTask({ + provider: provider as never, + launchOwnerUserId: 'user-1', + queuedMessage: { + provider: 'discord', + text: 'Build a fresh dashboard', + user: 'Matt', + userId: 'user-1', + // Interaction snowflake — not a valid Discord reaction target. + ts: 'interaction-new', + }, + metadata: { + communicationProvider: 'discord', + communicationChannelId: 'dm-1', + communicationMessageId: 'interaction-new', + }, + channel: { + channelId: 'dm-1', + channelName: 'Direct message', + channelType: 1, + isDirectMessage: true, + isThread: false, + }, + workspace: { + repoForPayload: 'acme/repo', + workspaceDisplayName: 'Acme', + }, + forceNewThread: true, + }); + + // No origin message id yet at enqueue; reaction coordinates arrive after ack. + expect(mocks.enqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: { + type: 'standard', + payload: expect.not.objectContaining({ + discordReactionMessageId: 'interaction-new', + }), + }, + }), + expect.anything(), + ); + expect(mocks.dbUpdate).toHaveBeenCalled(); + expect(provider.addReaction).toHaveBeenCalledWith({ + channelId: 'dm-1', + messageId: 'ack-dm-1', + name: '👀', + }); + }); + it('falls back to a detached thread when the anchor message was deleted', async () => { const reservedThread = { channelId: 'thread-41', diff --git a/apps/api/src/handlers/discord/callback-actions.ts b/apps/api/src/handlers/discord/callback-actions.ts index f1cd5226..db0bd736 100644 --- a/apps/api/src/handlers/discord/callback-actions.ts +++ b/apps/api/src/handlers/discord/callback-actions.ts @@ -1,4 +1,7 @@ -import { activeRunStatuses } from '@roomote/types'; +import { + activeRunStatuses, + type QueuedCommunicationMessage, +} from '@roomote/types'; import { and, db, @@ -14,7 +17,6 @@ import { import type { DiscordInteraction } from '@roomote/communication/discord-event'; import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; import { findDiscordMappedUserId } from '@roomote/sdk/server'; -import type { QueuedCommunicationMessage } from '@roomote/types'; import { apiLogger } from '../../logging.js'; import { cancelOrphanedWorkItemRunBestEffort } from '../tasks/orphaned-work-item-run.js'; @@ -32,6 +34,9 @@ import { import { claimDiscordSuggestionLaunch } from './setup-suggestions.js'; import { startNewDiscordTask } from './task-orchestration.js'; +/** Match Slack cancel reaction (`DEFAULT_SLACK_CANCEL_EMOJI`). */ +const DISCORD_CANCEL_REACTION_EMOJI = 'x'; + function parseCancelCallbackData(value: string | undefined): number | null { const match = /^discord:cancel:(\d+)$/u.exec(value ?? ''); if (!match) return null; @@ -60,6 +65,7 @@ async function findCancelableDiscordRun(runId: number, channelId: string) { status: true, sandboxServerUrl: true, actingUserId: true, + payload: true, }, with: { task: { columns: { initiatorUserId: true } }, @@ -67,6 +73,60 @@ async function findCancelableDiscordRun(runId: number, channelId: string) { }); } +function getDiscordCancelReactionTarget(payload: unknown): { + channelId: string; + messageId: string; +} | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const channelId = + typeof (payload as { discordReactionChannelId?: unknown }) + .discordReactionChannelId === 'string' + ? ( + payload as { discordReactionChannelId: string } + ).discordReactionChannelId.trim() + : ''; + const messageId = + typeof (payload as { discordReactionMessageId?: unknown }) + .discordReactionMessageId === 'string' + ? ( + payload as { discordReactionMessageId: string } + ).discordReactionMessageId.trim() + : ''; + + if (!channelId || !messageId) { + return null; + } + + return { channelId, messageId }; +} + +async function addDiscordCancelReactionBestEffort(input: { + provider: DiscordCommunicationProvider; + payload: unknown; +}): Promise { + const target = getDiscordCancelReactionTarget(input.payload); + if (!target) { + return; + } + + try { + await input.provider.addReaction({ + channelId: target.channelId, + messageId: target.messageId, + name: DISCORD_CANCEL_REACTION_EMOJI, + }); + } catch (error) { + apiLogger.warn( + `[discord] Failed to add cancel reaction for channel ${target.channelId} message ${target.messageId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + async function handleCancelCallback(input: { provider: DiscordCommunicationProvider; applicationId: string; @@ -138,6 +198,12 @@ async function handleCancelCallback(input: { }); const canceled = result.success || result.statusCode === 404 || result.statusCode === 409; + if (canceled) { + await addDiscordCancelReactionBestEffort({ + provider: input.provider, + payload: run.payload, + }); + } await replyToDiscordEvent({ provider: input.provider, applicationId: input.applicationId, diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index 711a5059..60137d9e 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -478,6 +478,21 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { return { ok: true, resumed: true, runId: resumed.id }; } + // Match Slack intake: ack the origin message with 👀 before launch work. + // Only real MESSAGE_CREATE messages can receive Discord reactions; slash- + // command interaction ids are not message targets and would 404. + if (message?.id) { + try { + await resolved.provider.addReaction({ + channelId: channel.channelId, + messageId: message.id, + name: '👀', + }); + } catch { + // Soft ack only. + } + } + const started = await startNewDiscordTask({ provider: resolved.provider, applicationId: resolved.applicationId, diff --git a/apps/api/src/handlers/discord/task-launch.ts b/apps/api/src/handlers/discord/task-launch.ts index 80f1640c..b910e0b9 100644 --- a/apps/api/src/handlers/discord/task-launch.ts +++ b/apps/api/src/handlers/discord/task-launch.ts @@ -5,7 +5,7 @@ import { type TaskInitiator, type TaskSpec, } from '@roomote/types'; -import { db, environments, eq } from '@roomote/db/server'; +import { db, environments, eq, sql, taskRuns } from '@roomote/db/server'; import { enqueueTask, getTaskUrl, @@ -37,6 +37,48 @@ const DISCORD_MESSAGE_ANCHORED_CHANNEL_TYPES = new Set([0, 5]); const DISCORD_PENDING_TASK_THREAD_PREFIX = 'discord:pending_task_thread:'; const DISCORD_PENDING_TASK_THREAD_TTL_SECONDS = 24 * 60 * 60; +type DiscordReactionTarget = { + channelId: string; + messageId: string; +}; + +/** + * Real MESSAGE_CREATE launches pin 👀 on the triggering message. Interaction + * launches (`/new`) have no message target at intake, so they permanently + * record the acknowledgement / thread-starter message as the reaction target + * after launch. + */ +function resolveDiscordOriginReactionTarget(input: { + channel: DiscordChannelContext; + metadata: DiscordEventCommunicationMetadata; +}): DiscordReactionTarget | null { + const messageId = input.metadata.communicationAnchorMessageId; + if (!messageId) { + return null; + } + return { + // Intake uses channel.channelId for eyes (thread or parent DM/channel). + channelId: input.channel.channelId, + messageId, + }; +} + +async function persistDiscordReactionTarget(input: { + runId: number; + reaction: DiscordReactionTarget; +}): Promise { + const patch = JSON.stringify({ + discordReactionChannelId: input.reaction.channelId, + discordReactionMessageId: input.reaction.messageId, + }); + await db + .update(taskRuns) + .set({ + payload: sql`coalesce(${taskRuns.payload}, '{}'::jsonb) || ${patch}::jsonb`, + }) + .where(eq(taskRuns.id, input.runId)); +} + export type DiscordChannelContext = { channelId: string; channelName: string; @@ -314,6 +356,20 @@ export async function launchDiscordTask(input: { createdThread?.channelId ?? input.metadata.communicationThreadId; const communicationMessageId = createdThread?.messageId ?? input.metadata.communicationMessageId; + const originReaction = resolveDiscordOriginReactionTarget({ + channel: input.channel, + metadata: input.metadata, + }); + // Prefer a real origin message when present. For interaction launches the + // thread starter (if any) is a valid target until the acknowledgement posts. + let reactionTarget: DiscordReactionTarget | null = + originReaction ?? + (createdThread?.messageId + ? { + channelId: createdThread.channelId, + messageId: createdThread.messageId, + } + : null); const task: Extract = { type: TaskPayloadKind.StandardTask, @@ -338,6 +394,12 @@ export async function launchDiscordTask(input: { ...(communicationMessageId ? { communicationMessageId } : {}), communicationSourceEventId: input.queuedMessage.ts, ...(createdThread ? { discordTaskThread: true } : {}), + ...(reactionTarget + ? { + discordReactionChannelId: reactionTarget.channelId, + discordReactionMessageId: reactionTarget.messageId, + } + : {}), }, }; @@ -405,6 +467,35 @@ export async function launchDiscordTask(input: { ...acknowledgementMessage, }); + // Interaction launches (`/new`) have no MESSAGE_CREATE origin. Persist the + // real acknowledgement (or thread-starter) message so terminal/cancel + // reactions have a valid Discord reaction target. + if (!originReaction && acknowledgement.messageId) { + reactionTarget = { + channelId: communicationThreadId ?? communicationChannelId, + messageId: acknowledgement.messageId, + }; + await persistDiscordReactionTarget({ + runId: launchResult.id, + reaction: reactionTarget, + }).catch((error) => { + console.warn( + `[discord] Failed to persist reaction target for run ${launchResult.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + try { + await input.provider.addReaction({ + channelId: reactionTarget.channelId, + messageId: reactionTarget.messageId, + name: '👀', + }); + } catch { + // Soft ack only — the launch already succeeded. + } + } + if (createdThread) { await forgetPendingTaskThread(input.queuedMessage.ts).catch((error) => { console.warn( diff --git a/apps/api/src/handlers/github/__tests__/notifyPullRequestTerminalStatus.test.ts b/apps/api/src/handlers/github/__tests__/notifyPullRequestTerminalStatus.test.ts index 94e349ae..c66fb5c3 100644 --- a/apps/api/src/handlers/github/__tests__/notifyPullRequestTerminalStatus.test.ts +++ b/apps/api/src/handlers/github/__tests__/notifyPullRequestTerminalStatus.test.ts @@ -126,6 +126,9 @@ const discordPayload = { communicationProvider: 'discord', communicationChannelId: 'channel-1', communicationThreadId: 'discord-thread-1', + discordReactionChannelId: 'channel-1', + discordReactionMessageId: 'origin-msg-1', + discordTaskThread: true, }; const teamsAdapter = { @@ -138,6 +141,8 @@ const telegramAdapter = { const discordAdapter = { postMessage: mockPostMessage, + addReaction: mockAddReaction, + removeReaction: mockRemoveReaction, }; const baseParams = { @@ -288,6 +293,16 @@ describe('notifyPullRequestTerminalStatus', () => { text: '[Test PR](https://github.com/owner/repo/pull/42) was **merged** by merger', textFormat: 'markdown', }); + expect(mockAddReaction).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'origin-msg-1', + name: 'white_check_mark', + }); + expect(mockRemoveReaction).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'origin-msg-1', + name: 'eyes', + }); }); it('emits a Linear closing response activity', async () => { diff --git a/apps/api/src/handlers/github/notifyPullRequestTerminalStatus.ts b/apps/api/src/handlers/github/notifyPullRequestTerminalStatus.ts index afc6e996..a7bd2a28 100644 --- a/apps/api/src/handlers/github/notifyPullRequestTerminalStatus.ts +++ b/apps/api/src/handlers/github/notifyPullRequestTerminalStatus.ts @@ -108,6 +108,11 @@ type TelegramTarget = { type DiscordTarget = { channelId: string; threadId?: string; + /** Valid Discord message+channel for platform reactions when present. */ + reaction?: { + channelId: string; + messageId: string; + }; }; function getTeamsTarget(payload: unknown): TeamsTarget | null { @@ -176,13 +181,40 @@ function getDiscordTarget(payload: unknown): DiscordTarget | null { } const threadId = getCommunicationThreadIdFromTaskPayload(payload); + const reactionChannelId = getNonEmptyPayloadString( + payload, + 'discordReactionChannelId', + ); + const reactionMessageId = getNonEmptyPayloadString( + payload, + 'discordReactionMessageId', + ); return { channelId, ...(threadId ? { threadId } : {}), + ...(reactionChannelId && reactionMessageId + ? { + reaction: { + channelId: reactionChannelId, + messageId: reactionMessageId, + }, + } + : {}), }; } +function getNonEmptyPayloadString( + payload: unknown, + key: string, +): string | null { + if (!payload || typeof payload !== 'object') { + return null; + } + const value = (payload as Record)[key]; + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + async function resolveLinearClient() { const connection = await findLinearDeploymentMcpConnection(); @@ -494,6 +526,10 @@ async function deliverDiscordTerminalStatus({ formatLink: formatMarkdownLink, formatStatus: (value) => `**${value}**`, }); + // Match Slack terminal reactions: check on merge, thumbsdown on closed. + const terminalReaction = + status === 'closed' ? SLACK_PR_CLOSED_REACTION_EMOJI : 'white_check_mark'; + const ackEmoji = 'eyes'; const notifiedConversations = new Set(); @@ -512,6 +548,22 @@ async function deliverDiscordTerminalStatus({ textFormat: 'markdown', }); + const originReaction = target.reaction; + if (originReaction && provider.addReaction) { + await Promise.all([ + provider.addReaction({ + channelId: originReaction.channelId, + messageId: originReaction.messageId, + name: terminalReaction, + }), + provider.removeReaction?.({ + channelId: originReaction.channelId, + messageId: originReaction.messageId, + name: ackEmoji, + }) ?? Promise.resolve(), + ]); + } + notifiedConversations.add(conversationKey); console.log( diff --git a/apps/worker/src/run-task/__tests__/communication-message-prompt.test.ts b/apps/worker/src/run-task/__tests__/communication-message-prompt.test.ts index 8fc768ca..dfcba55f 100644 --- a/apps/worker/src/run-task/__tests__/communication-message-prompt.test.ts +++ b/apps/worker/src/run-task/__tests__/communication-message-prompt.test.ts @@ -41,4 +41,26 @@ describe('wrapCommunicationMessage', () => { '\nwatch out for <tags> & "quotes"\n', ); }); + + it('prefixes Discord turns with an emoji reaction policy when present', () => { + expect( + wrapCommunicationMessage('discord', { + ts: 'message-1', + user: 'Ada', + channel: 'channel-1', + text: 'please continue', + turnPolicy: { reactionsAllowed: true }, + }), + ).toBe( + [ + '', + 'Emoji reactions are allowed on the current discord message. Prefer `send_chat_reaction_emoji` instead of a short text acknowledgement when a lightweight acknowledgement or emoji-only answer is enough.', + '', + '', + '', + 'please continue', + '', + ].join('\n'), + ); + }); }); diff --git a/apps/worker/src/run-task/__tests__/run-task.test.ts b/apps/worker/src/run-task/__tests__/run-task.test.ts index bd4f46c5..32b53ff1 100644 --- a/apps/worker/src/run-task/__tests__/run-task.test.ts +++ b/apps/worker/src/run-task/__tests__/run-task.test.ts @@ -1260,6 +1260,71 @@ describe('runTask', () => { ); }); + it('initializes Discord reply satisfaction state without first-turn reactions', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(678_901); + + try { + await runTask({ + taskRun: { + id: 109, + taskId: 'task-109', + payloadKind: TaskPayloadKind.StandardTask, + harness: 'opencode-server', + payload: { + repo: 'org/repo', + description: 'do the thing', + communicationProvider: 'discord', + communicationChannelId: 'channel-1', + communicationThreadId: 'thread-1', + communicationMessageId: 'message-1', + }, + result: null, + } as never, + envVars: {}, + workspacePath: '/tmp/workspace', + prompt: '', + harnessInstructions: undefined, + agentInstructions: undefined, + environmentConfig: undefined, + callbacks: {}, + context: {}, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + } as never, + harnessSessionId: undefined, + workerEnv: { + authToken: 'cloud-token', + roomoteAppUrl: 'https://api.example.test', + trpcUrl: 'https://web.example.test', + buildUserFacingEnv: vi.fn(() => ({ + HOME: '/tmp/home', + PATH: '/usr/bin', + })), + } as never, + }); + } finally { + nowSpy.mockRestore(); + } + + const stateFilePath = + '/tmp/workspace/.roomote-runtime-home/.config/opencode/roomote-slack-reply-satisfaction.json'; + expect(writeFileSyncMock).toHaveBeenCalledWith( + stateFilePath, + JSON.stringify({ + startedAtMs: 678_901, + currentTurnRequiresInitialAck: true, + currentTurnMessageTs: 'message-1', + currentTurnStartedAtMs: 678_901, + // Chat-launched first turns require a real reply, not a reaction. + currentTurnReactionsAllowed: false, + }), + 'utf8', + ); + }); + it('marks late-bound automation execution tasks as requiring a terminal closeout', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(345_678); diff --git a/apps/worker/src/run-task/communication-message-prompt.ts b/apps/worker/src/run-task/communication-message-prompt.ts index f8b12432..8bf062d3 100644 --- a/apps/worker/src/run-task/communication-message-prompt.ts +++ b/apps/worker/src/run-task/communication-message-prompt.ts @@ -5,7 +5,7 @@ import type { type CommunicationPromptMessage = Pick< QueuedCommunicationMessage, - 'channel' | 'text' | 'threadTs' | 'ts' | 'user' + 'channel' | 'text' | 'threadTs' | 'ts' | 'user' | 'turnPolicy' >; function escapeCommunicationPromptContent(value: string): string { @@ -19,6 +19,22 @@ function escapeCommunicationPromptAttribute(value: string): string { return escapeCommunicationPromptContent(value).replaceAll('"', '"'); } +function wrapCommunicationTurnPolicy( + provider: CommunicationProvider, + turnPolicy: NonNullable, +): string { + const reactionsAllowed = turnPolicy.reactionsAllowed === true; + // Match Slack follow-up policy: when reactions are allowed on the turn, + // prefer a lightweight emoji ack over short text when that is enough. + const preferEmojiAck = reactionsAllowed; + const tag = `${provider}_turn_policy`; + const guidance = reactionsAllowed + ? `Emoji reactions are allowed on the current ${provider} message. Prefer \`send_chat_reaction_emoji\` instead of a short text acknowledgement when a lightweight acknowledgement or emoji-only answer is enough.` + : `Emoji reactions are not allowed on the current ${provider} message. Use \`send_chat_reply\` for acknowledgements and lightweight clarification. Use \`request_user_input\` only when the task actually needs structured or private input from the user.`; + + return `<${tag} reactions_allowed="${reactionsAllowed ? 'true' : 'false'}" prefer_emoji_ack="${preferEmojiAck ? 'true' : 'false'}">\n${escapeCommunicationPromptContent(guidance)}\n`; +} + export function wrapCommunicationMessage( provider: CommunicationProvider, message: CommunicationPromptMessage, @@ -46,5 +62,11 @@ export function wrapCommunicationMessage( ); } - return `\n${escapeCommunicationPromptContent(message.text.trim())}\n`; + const body = `\n${escapeCommunicationPromptContent(message.text.trim())}\n`; + + if (!message.turnPolicy) { + return body; + } + + return `${wrapCommunicationTurnPolicy(provider, message.turnPolicy)}\n\n${body}`; } 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 dcc499ad..16236f62 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts @@ -5,7 +5,10 @@ import { } from '@roomote/types'; import type { ResolvedTaskCommitAuthor } from '../../commit-author'; -import { slackAppMention } from '../slackAppMention'; +import { + buildChatProviderMessageInstructions, + slackAppMention, +} from '../slackAppMention'; function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; @@ -600,3 +603,22 @@ describe('slackAppMention', () => { ); }); }); + +describe('buildChatProviderMessageInstructions', () => { + it.each(['discord', 'teams', 'telegram'] as const)( + 'allows send_chat_reaction_emoji for %s turns (not Slack-only)', + (provider) => { + const instructions = buildChatProviderMessageInstructions(provider); + + expect(instructions).toContain('send_chat_reaction_emoji'); + expect(instructions).toContain('white_check_mark'); + expect(instructions).toContain('thumbsdown'); + expect(instructions).toContain( + 'Do not use Slack-only tools such as `post_to_slack_channel`', + ); + expect(instructions).not.toContain( + 'Do not use Slack-only tools such as `send_chat_reaction_emoji`', + ); + }, + ); +}); diff --git a/packages/cloud-agents/src/server/workflows/slackAppMention.ts b/packages/cloud-agents/src/server/workflows/slackAppMention.ts index 39083e81..7bbec5a4 100644 --- a/packages/cloud-agents/src/server/workflows/slackAppMention.ts +++ b/packages/cloud-agents/src/server/workflows/slackAppMention.ts @@ -174,14 +174,14 @@ export function buildChatProviderMessageInstructions( <${tag}_visibility_contract> Treat the originating ${label} thread as the user-facing conversation for this task. - Task UI commentary, todo updates, internal reasoning, and ordinary tool results are not visible in ${label}. ${label}-visible lifecycle replies use \`send_chat_reply\`. + Task UI commentary, todo updates, internal reasoning, and ordinary tool results are not visible in ${label}. ${label}-visible lifecycle replies use \`send_chat_reply\`. Lightweight emoji acks use \`send_chat_reaction_emoji\` when reactions are allowed for the current turn. Before calling \`send_chat_reply\`, choose the current lifecycle purpose: \`ack\`, \`progress\`, \`closeout\`, or \`clarification\`. The message content should match that purpose. \`ack\`, \`progress\`, and \`clarification\` replies keep the ${label} turn open. Before finalizing the task, use \`send_chat_reply\` with \`purpose\` set to \`closeout\`. Use \`request_user_input\` only when structured or private input is genuinely required. It does not replace a ${label}-visible closeout. <${tag}_turn_lifecycle> - \`ack\`: Send one early ${label}-visible acknowledgement before substantial work that will not otherwise post to ${label} when the answer is not immediate. + \`ack\`: Send one early ${label}-visible acknowledgement before substantial work that will not otherwise post to ${label} when the answer is not immediate. When the current turn allows emoji reactions (see an optional \`<${tag}_turn_policy prefer_emoji_ack="true">\` block, or \`reactions_allowed="true"\` on the inbound message policy) and a lightweight acknowledgement is enough, prefer \`send_chat_reaction_emoji\` over a short text ack. When the ack needs words, reactions are not allowed, or this is the first chat turn of a task, use \`send_chat_reply\`. \`progress\`: After an acknowledgement, send progress only when the update adds decision-useful state, reports a blocker, asks for input, changes approach, or prevents more than 10 minutes of ${label}-visible silence during active work. \`closeout\`: Send one ${label}-visible closeout when the turn has an answer, completed result, explicit blocker, or a paused-waiting state that you explain in prose. \`clarification\`: Ask lightweight non-secret questions with \`send_chat_reply\` only when thread context and available tools do not already resolve the question well enough to continue. @@ -195,8 +195,9 @@ export function buildChatProviderMessageInstructions( <${tag}_response_delivery> Use \`send_chat_reply\` for lifecycle replies in the originating ${label} thread when the reply needs words: early acknowledgements, useful progress, closeouts, and lightweight clarifications. - Do not use Slack-only tools such as \`send_chat_reaction_emoji\` or \`post_to_slack_channel\` for ${label} turns. - Every new directed ${label} user turn that you answer still needs its own fresh ${label}-visible \`send_chat_reply\`. + Use \`send_chat_reaction_emoji\` for lightweight acknowledgements, confirmations, or emoji-only answers when the current turn allows reactions. Choose the reaction that best matches the intent: \`eyes\` for taking a look, \`thumbsup\` for acknowledgement/go-ahead, \`white_check_mark\` for completed work, \`x\` or \`thumbsdown\` for rejection/failure, and another mapped reaction when it fits better. + Do not use Slack-only tools such as \`post_to_slack_channel\` for ${label} turns. + Every new directed ${label} user turn that you answer still needs its own fresh ${label}-visible response. A prior turn's reply or reaction does not satisfy a later turn. An emoji reaction only satisfies a lightweight ack when the current policy allows reactions; a first-turn or closeout still needs \`send_chat_reply\`. `.trim(); diff --git a/packages/communication/src/__tests__/discord-provider.test.ts b/packages/communication/src/__tests__/discord-provider.test.ts index 63f8581a..2e9fddf5 100644 --- a/packages/communication/src/__tests__/discord-provider.test.ts +++ b/packages/communication/src/__tests__/discord-provider.test.ts @@ -485,6 +485,31 @@ describe('DiscordCommunicationProvider', () => { expect(server.state.messages[channelId]).toHaveLength(0); }); + it('maps Slack-style reaction names onto Discord unicode emoji', async () => { + const { server, provider } = createHarness(); + const channelId = '400000000000000002'; + const sent = await provider.postMessage({ channelId, text: 'ack me' }); + + for (const [name, emoji] of [ + ['eyes', '👀'], + [':white_check_mark:', '✅'], + ['x', '❌'], + ['thumbsdown', '👎'], + ['+1', '👍'], + ] as const) { + await provider.addReaction({ + channelId, + messageId: sent.messageId, + name, + }); + expect(server.state.reactions.at(-1)).toEqual({ + channelId, + messageId: sent.messageId, + emoji, + }); + } + }); + it('defers and completes interaction responses without bot authorization', async () => { const { server, provider } = createHarness(); diff --git a/packages/communication/src/discord-provider.ts b/packages/communication/src/discord-provider.ts index 5471c62b..74a298e0 100644 --- a/packages/communication/src/discord-provider.ts +++ b/packages/communication/src/discord-provider.ts @@ -786,7 +786,7 @@ export class DiscordCommunicationProvider implements CommunicationProviderAdapte messageId: string; name: string; }): Promise { - const emoji = encodeURIComponent(input.name.replace(/^:|:$/gu, '')); + const emoji = encodeURIComponent(resolveDiscordReactionEmoji(input.name)); await this.request( 'PUT', `/channels/${input.channelId}/messages/${input.messageId}/reactions/${emoji}/@me`, @@ -796,6 +796,21 @@ export class DiscordCommunicationProvider implements CommunicationProviderAdapte return { provider: 'discord', ...input }; } + async removeReaction(input: { + channelId: string; + messageId: string; + name: string; + }): Promise { + const emoji = encodeURIComponent(resolveDiscordReactionEmoji(input.name)); + await this.request( + 'DELETE', + `/channels/${input.channelId}/messages/${input.messageId}/reactions/${emoji}/@me`, + undefined, + { retryNetworkErrors: true, retryServerErrors: true }, + ); + return { provider: 'discord', ...input }; + } + /** * Shows the bot as typing in the channel or thread for ~10 seconds (or * until the bot's next message lands). Callers that deliver slowly should @@ -1349,3 +1364,64 @@ export class DiscordCommunicationProvider implements CommunicationProviderAdapte throw lastError ?? new Error(`Discord ${method} ${path} failed.`); } } + +/** + * Map Slack-style reaction names used by the agent tooling onto Discord + * unicode emoji. Discord's reaction API expects encoded unicode (or custom + * emoji names), not Slack `:eyes:`-style aliases. + */ +const DISCORD_REACTION_EMOJI_BY_NAME: Record = { + eyes: '👀', + thumbsup: '👍', + '+1': '👍', + like: '👍', + thumbsdown: '👎', + '-1': '👎', + white_check_mark: '✅', + heavy_check_mark: '✔️', + checkered_flag: '🏁', + x: '❌', + negative_squared_cross_mark: '❎', + no_entry_sign: '🚫', + tada: '🎉', + heart: '❤️', + fire: '🔥', + clap: '👏', + think: '🤔', + thinking_face: '🤔', + ok_hand: '👌', + pray: '🙏', + '100': '💯', + wave: '👋', + trophy: '🏆', + handshake: '🤝', + saluting_face: '🫡', + rocket: '🚀', + joy: '😆', + laugh: '😆', + smile: '😄', + open_mouth: '😮', + surprised: '😮', + scream: '😱', + sad: '😢', + cry: '😢', + angry: '😠', + rage: '😡', + ghost: '👻', + hourglass: '⏳', + hourglass_flowing_sand: '⏳', +}; + +function resolveDiscordReactionEmoji(name: string): string { + const cleaned = name.replace(/^:|:$/gu, '').trim(); + if (!cleaned) { + return cleaned; + } + + const mapped = DISCORD_REACTION_EMOJI_BY_NAME[cleaned.toLowerCase()]; + if (mapped) { + return mapped; + } + + return cleaned; +} diff --git a/packages/communication/src/mock-discord-server.ts b/packages/communication/src/mock-discord-server.ts index 8f224baf..4dbe8b15 100644 --- a/packages/communication/src/mock-discord-server.ts +++ b/packages/communication/src/mock-discord-server.ts @@ -558,6 +558,20 @@ export class MockDiscordServer { }); return jsonResponse(undefined, 204); } + if (reaction && method === 'DELETE') { + const channelId = reaction[1] ?? ''; + const messageId = reaction[2] ?? ''; + const emoji = decodeURIComponent(reaction[3] ?? ''); + this.state.reactions = this.state.reactions.filter( + (item) => + !( + item.channelId === channelId && + item.messageId === messageId && + item.emoji === emoji + ), + ); + return jsonResponse(undefined, 204); + } if ( method === 'POST' && diff --git a/packages/communication/src/provider.ts b/packages/communication/src/provider.ts index 6a7014ae..2b265a2c 100644 --- a/packages/communication/src/provider.ts +++ b/packages/communication/src/provider.ts @@ -126,6 +126,11 @@ export interface CommunicationProviderAdapter { messageId: string; name: string; }): Promise; + removeReaction?(input: { + channelId: string; + messageId: string; + name: string; + }): Promise; } export interface CommunicationInboundProvider { diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index 3450db63..bef0362c 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -926,6 +926,16 @@ const sharedTaskPayloadSchema = z.object({ communicationMessageId: z.string().optional(), /** Provider event that caused this fresh launch; used for idempotent retries. */ communicationSourceEventId: z.string().optional(), + /** + * Discord channel hosting the origin reaction target. Always a channel that + * contains `discordReactionMessageId` (never an interaction id). + */ + discordReactionChannelId: z.string().optional(), + /** + * Discord message id that platform reactions (👀 / terminal / cancel) target. + * Must be a real message id, never an interaction id. + */ + discordReactionMessageId: z.string().optional(), /** True when the Telegram topic was created specifically for this task. */ telegramTaskTopic: z.boolean().optional(), /** True when the Discord thread/forum post was created for this task. */