diff --git a/CHANGELOG.md b/CHANGELOG.md index e1b90c77b9..8d30a238c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Orchestrator now shows emoji reaction feedback on Slack messages (👀 → 🔄 → ✅/❌) during workflow execution. + ## [0.3.10] - 2026-04-29 Maintainer workflow suite, loop output variables, and broad workflow engine fixes diff --git a/packages/adapters/src/chat/slack/adapter.test.ts b/packages/adapters/src/chat/slack/adapter.test.ts index 283353dee1..0b76502c77 100644 --- a/packages/adapters/src/chat/slack/adapter.test.ts +++ b/packages/adapters/src/chat/slack/adapter.test.ts @@ -26,6 +26,8 @@ mock.module('@archon/paths', () => ({ // Create mock functions const mockPostMessage = mock(() => Promise.resolve(undefined)); const mockReplies = mock(() => Promise.resolve({ messages: [] })); +const mockReactionsAdd = mock(() => Promise.resolve(undefined)); +const mockReactionsRemove = mock(() => Promise.resolve(undefined)); const mockEvent = mock(() => {}); const mockStart = mock(() => Promise.resolve(undefined)); const mockStop = mock(() => Promise.resolve(undefined)); @@ -38,6 +40,10 @@ const mockApp = { conversations: { replies: mockReplies, }, + reactions: { + add: mockReactionsAdd, + remove: mockReactionsRemove, + }, }, event: mockEvent, start: mockStart, @@ -366,4 +372,94 @@ describe('SlackAdapter', () => { ); }); }); + + describe('addReaction', () => { + let adapter: SlackAdapter; + + beforeEach(() => { + mockReactionsAdd.mockClear(); + adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + }); + + test('should add reaction to valid conversation ID', async () => { + await adapter.addReaction('C123:1234567890.123456', 'eyes'); + + expect(mockReactionsAdd).toHaveBeenCalledWith({ + channel: 'C123', + name: 'eyes', + timestamp: '1234567890.123456', + }); + }); + + test('should handle different emoji names', async () => { + await adapter.addReaction('C456:1111111111.111111', 'white_check_mark'); + await adapter.addReaction('C789:2222222222.222222', 'x'); + + expect(mockReactionsAdd).toHaveBeenCalledTimes(2); + expect(mockReactionsAdd).toHaveBeenNthCalledWith(1, { + channel: 'C456', + name: 'white_check_mark', + timestamp: '1111111111.111111', + }); + expect(mockReactionsAdd).toHaveBeenNthCalledWith(2, { + channel: 'C789', + name: 'x', + timestamp: '2222222222.222222', + }); + }); + + test('should silently handle invalid conversation ID format', async () => { + // No colon separator - should be ignored + await adapter.addReaction('invalid-id-no-colon', 'eyes'); + + expect(mockReactionsAdd).not.toHaveBeenCalled(); + }); + + test('should silently handle missing timestamp', async () => { + await adapter.addReaction('C123:', 'eyes'); + + expect(mockReactionsAdd).not.toHaveBeenCalled(); + }); + }); + + describe('removeReaction', () => { + let adapter: SlackAdapter; + + beforeEach(() => { + mockReactionsRemove.mockClear(); + adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + }); + + test('should remove reaction from valid conversation ID', async () => { + await adapter.removeReaction('C123:1234567890.123456', 'eyes'); + + expect(mockReactionsRemove).toHaveBeenCalledWith({ + channel: 'C123', + name: 'eyes', + timestamp: '1234567890.123456', + }); + }); + + test('should handle different emoji names', async () => { + await adapter.removeReaction('C456:1111111111.111111', 'arrows_counterclockwise'); + + expect(mockReactionsRemove).toHaveBeenCalledWith({ + channel: 'C456', + name: 'arrows_counterclockwise', + timestamp: '1111111111.111111', + }); + }); + + test('should silently handle invalid conversation ID format', async () => { + await adapter.removeReaction('invalid-id-no-colon', 'eyes'); + + expect(mockReactionsRemove).not.toHaveBeenCalled(); + }); + + test('should silently handle missing timestamp', async () => { + await adapter.removeReaction('C123:', 'eyes'); + + expect(mockReactionsRemove).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/adapters/src/chat/slack/adapter.ts b/packages/adapters/src/chat/slack/adapter.ts index e74a069356..29ff886328 100644 --- a/packages/adapters/src/chat/slack/adapter.ts +++ b/packages/adapters/src/chat/slack/adapter.ts @@ -198,6 +198,58 @@ export class SlackAdapter implements IPlatformAdapter { return `${event.channel}:${event.ts}`; } + /** + * Add a reaction (emoji) to a message + * @param conversationId - Slack: "channel:timestamp" + * @param reaction - Emoji shortname (e.g., "eyes", "white_check_mark", "x") + */ + async addReaction(conversationId: string, reaction: string): Promise { + // Parse conversationId: "channel:timestamp" format + const [channelId, timestamp] = conversationId.split(':'); + if (!channelId || !timestamp) { + getLog().warn({ conversationId, reaction }, 'slack.reaction_invalid_conversation_id'); + return; + } + + try { + await this.app.client.reactions.add({ + channel: channelId, + name: reaction, + timestamp: timestamp, + }); + getLog().debug({ channelId, timestamp, reaction }, 'slack.reaction_added'); + } catch (error) { + const err = error as Error; + getLog().warn({ err, channelId, timestamp, reaction }, 'slack.reaction_failed'); + } + } + + /** + * Remove a reaction from a message + * @param conversationId - Slack: "channel:timestamp" + * @param reaction - Emoji shortname to remove (e.g., "eyes", "arrows_counterclockwise") + */ + async removeReaction(conversationId: string, reaction: string): Promise { + // Parse conversationId: "channel:timestamp" format + const [channelId, timestamp] = conversationId.split(':'); + if (!channelId || !timestamp) { + getLog().warn({ conversationId, reaction }, 'slack.remove_reaction_invalid_conversation_id'); + return; + } + + try { + await this.app.client.reactions.remove({ + channel: channelId, + name: reaction, + timestamp: timestamp, + }); + getLog().debug({ channelId, timestamp, reaction }, 'slack.reaction_removed'); + } catch (error) { + const err = error as Error; + getLog().warn({ err, channelId, timestamp, reaction }, 'slack.remove_reaction_failed'); + } + } + /** * Strip bot mention from message text and normalize Slack formatting */ diff --git a/packages/core/package.json b/packages/core/package.json index 3f2b949386..dad9f5472d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,7 +22,7 @@ "./state/*": "./src/state/*.ts" }, "scripts": { - "test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/connection.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", + "test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/connection.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts && bun test src/orchestrator/safe-reaction.test.ts", "type-check": "bun x tsc --noEmit", "build": "echo 'No build needed - Bun runs TypeScript directly'" }, diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 8521e83a82..a76c84ab2f 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -69,6 +69,40 @@ const MAX_BATCH_ASSISTANT_CHUNKS = 20; /** Max total chunks (assistant + tool) to keep in batch mode */ const MAX_BATCH_TOTAL_CHUNKS = 200; +// ─── Reaction Helpers ─────────────────────────────────────────────────────── + +/** + * Safely add a reaction to a message. + * Errors are logged but not thrown to avoid breaking core message handling. + */ +export async function safeAddReaction( + platform: IPlatformAdapter, + conversationId: string, + reaction: string +): Promise { + try { + await platform.addReaction?.(conversationId, reaction); + } catch (error) { + getLog().warn({ err: error, conversationId, reaction }, 'orchestrator.reaction_add_failed'); + } +} + +/** + * Safely remove a reaction from a message. + * Errors are logged but not thrown to avoid breaking core message handling. + */ +export async function safeRemoveReaction( + platform: IPlatformAdapter, + conversationId: string, + reaction: string +): Promise { + try { + await platform.removeReaction?.(conversationId, reaction); + } catch (error) { + getLog().warn({ err: error, conversationId, reaction }, 'orchestrator.reaction_remove_failed'); + } +} + // ─── Types ────────────────────────────────────────────────────────────────── export interface WorkflowInvocation { @@ -547,6 +581,9 @@ export async function handleMessage( try { getLog().debug({ conversationId }, 'orchestrator_message_received'); + // React with 👀 to acknowledge message received + await safeAddReaction(platform, conversationId, 'eyes'); + // 1. Get/create conversation and inherit thread context let conversation = await db.getOrCreateConversation( platform.getPlatformType(), @@ -836,6 +873,9 @@ export async function handleMessage( const aiClient = getAgentProvider(conversation.ai_assistant_type); getLog().debug({ assistantType: conversation.ai_assistant_type }, 'sending_to_ai'); + // React with 🔄 when work begins + await safeAddReaction(platform, conversationId, 'arrows_counterclockwise'); + // Reuse the config already loaded during workflow discovery (avoids a second disk read). // Fall back to loadConfig only when no codebase is scoped (discoveredConfig is undefined). const config = discoveredConfig ?? (await loadConfig()); @@ -905,9 +945,34 @@ export async function handleMessage( } getLog().debug({ conversationId }, 'orchestrator_message_completed'); + + // Reaction cleanup and final status (ensures even on early returns) + try { + // Remove intermediate reactions before adding final status + await safeRemoveReaction(platform, conversationId, 'eyes'); + await safeRemoveReaction(platform, conversationId, 'arrows_counterclockwise'); + + // React with ✅ on success + await safeAddReaction(platform, conversationId, 'white_check_mark'); + } catch { + // Silently ignore reaction errors + } } catch (error) { const err = toError(error); getLog().error({ err, conversationId }, 'orchestrator_message_failed'); + + // Reaction cleanup and final status (ensures even on exceptions) + try { + // Remove intermediate reactions before adding final status + await safeRemoveReaction(platform, conversationId, 'eyes'); + await safeRemoveReaction(platform, conversationId, 'arrows_counterclockwise'); + + // React with ❌ on failure + await safeAddReaction(platform, conversationId, 'x'); + } catch { + // Silently ignore reaction errors + } + const userMessage = classifyAndFormatError(err); try { await platform.sendMessage(conversationId, userMessage); diff --git a/packages/core/src/orchestrator/safe-reaction.test.ts b/packages/core/src/orchestrator/safe-reaction.test.ts new file mode 100644 index 0000000000..07353759eb --- /dev/null +++ b/packages/core/src/orchestrator/safe-reaction.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, mock, beforeEach } from 'bun:test'; +import type { IPlatformAdapter } from '../types'; +import { safeAddReaction, safeRemoveReaction } from './orchestrator-agent'; + +// Minimal mock of IPlatformAdapter for reaction testing +const createMockPlatform = (): IPlatformAdapter => { + const platform: IPlatformAdapter = { + getPlatformType: mock(() => 'slack'), + sendMessage: mock(() => Promise.resolve()), + }; + return platform; +}; + +describe('Reaction Helpers', () => { + let mockPlatform: IPlatformAdapter; + + beforeEach(() => { + mockPlatform = createMockPlatform(); + }); + + describe('safeAddReaction', () => { + it('calls platform.addReaction when it exists', async () => { + const addReactionSpy = mock(() => Promise.resolve()); + mockPlatform.addReaction = addReactionSpy; + + await safeAddReaction(mockPlatform, 'channel:123', 'eyes'); + + expect(addReactionSpy).toHaveBeenCalledWith('channel:123', 'eyes'); + }); + + it('gracefully returns when platform.addReaction is undefined', async () => { + mockPlatform.addReaction = undefined; + + await expect(safeAddReaction(mockPlatform, 'channel:123', 'eyes')).resolves.toBeUndefined(); + }); + + it('catches and logs errors without throwing', async () => { + const error = new Error('API error'); + mockPlatform.addReaction = mock(() => Promise.reject(error)); + + await expect(safeAddReaction(mockPlatform, 'channel:123', 'eyes')).resolves.toBeUndefined(); + }); + }); + + describe('safeRemoveReaction', () => { + it('calls platform.removeReaction when it exists', async () => { + const removeReactionSpy = mock(() => Promise.resolve()); + mockPlatform.removeReaction = removeReactionSpy; + + await safeRemoveReaction(mockPlatform, 'channel:123', 'eyes'); + + expect(removeReactionSpy).toHaveBeenCalledWith('channel:123', 'eyes'); + }); + + it('gracefully returns when platform.removeReaction is undefined', async () => { + mockPlatform.removeReaction = undefined; + + await expect( + safeRemoveReaction(mockPlatform, 'channel:123', 'eyes') + ).resolves.toBeUndefined(); + }); + + it('catches and logs errors without throwing', async () => { + const error = new Error('API error'); + mockPlatform.removeReaction = mock(() => Promise.reject(error)); + + await expect( + safeRemoveReaction(mockPlatform, 'channel:123', 'eyes') + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 74966e3b2c..fcf4b72e28 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -160,6 +160,20 @@ export interface IPlatformAdapter { /** Retract previously streamed text (used when workflow routing intercepts) */ emitRetract?(conversationId: string): Promise; + + /** + * Add a reaction (emoji) to a message + * @param conversationId - The conversation ID (for Slack: "channel:timestamp") + * @param reaction - Emoji name (Slack shortname, e.g., "eyes", "white_check_mark", "x") + */ + addReaction?(conversationId: string, reaction: string): Promise; + + /** + * Remove a reaction from a message + * @param conversationId - The conversation ID (for Slack: "channel:timestamp") + * @param reaction - Emoji name to remove + */ + removeReaction?(conversationId: string, reaction: string): Promise; } /** diff --git a/packages/docs-web/src/content/docs/adapters/slack.md b/packages/docs-web/src/content/docs/adapters/slack.md index ce53956793..c1a49f54f4 100644 --- a/packages/docs-web/src/content/docs/adapters/slack.md +++ b/packages/docs-web/src/content/docs/adapters/slack.md @@ -55,6 +55,7 @@ Archon uses **Socket Mode** for Slack integration, which means: 3. Add these scopes to bot token scopes: - `app_mentions:read` -- Receive @mention events - `chat:write` -- Send messages + - `reactions:write` -- Add emoji reactions to messages (👀 → 🔄 → ✅/❌ feedback) - `channels:history` -- Read messages in public channels (for thread context) - `channels:join` -- Allow bot to join public channels - `groups:history` -- Read messages in private channels (optional) @@ -148,6 +149,19 @@ You can also DM the bot directly -- no @mention needed: /help ``` +## Reaction Feedback + +Archon displays emoji reactions on your messages to show workflow progress: + +| Reaction | Meaning | +|----------|---------| +| 👀 | Message received, processing started | +| 🔄 | AI is working on your request | +| ✅ | Workflow completed successfully | +| ❌ | Workflow failed (check the error message) | + +The reactions are automatically added and cleaned up during execution. If the `reactions:write` scope is not granted, the feature silently degrades without affecting core functionality. + ## Troubleshooting ### Bot Doesn't Respond