From cee0fd01877df4ed3c5ff2d8003564ac9b138a60 Mon Sep 17 00:00:00 2001 From: Rasmus Widing <152263317+Wirasm@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:20:24 +0300 Subject: [PATCH 1/3] feat(slack): add message reactions to track conversation status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add emoji reactions to Slack messages to give users visual feedback on conversation state: - 👀 (eyes) when message is acknowledged - 🔄 (arrows_counterclockwise) when work begins - Clean up intermediate reactions before final status - ✅ (white_check_mark) on success - ❌ (x) on failure The addReaction/removeReaction methods are optional in IPlatformAdapter to maintain backwards compatibility with other platforms. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adapters/src/chat/slack/adapter.test.ts | 96 +++++++++++++++++++ packages/adapters/src/chat/slack/adapter.ts | 52 ++++++++++ packages/cli/src/commands/setup.test.ts | 16 ++-- packages/cli/src/commands/setup.ts | 15 ++- .../src/orchestrator/orchestrator-agent.ts | 21 ++++ packages/core/src/types/index.ts | 14 +++ 6 files changed, 203 insertions(+), 11 deletions(-) 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..15b1d6e997 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 - Format: "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 - Format: "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/cli/src/commands/setup.test.ts b/packages/cli/src/commands/setup.test.ts index a0fa7373b5..03a6b32d60 100644 --- a/packages/cli/src/commands/setup.test.ts +++ b/packages/cli/src/commands/setup.test.ts @@ -407,11 +407,11 @@ CODEX_ACCOUNT_ID=account1 }); describe('copyArchonSkill', () => { - it('should create skill files in target directory', () => { + it('should create skill files in target directory', async () => { const target = join(TEST_DIR, 'skill-target'); mkdirSync(target, { recursive: true }); - copyArchonSkill(target); + await copyArchonSkill(target); expect(existsSync(join(target, '.claude', 'skills', 'archon', 'SKILL.md'))).toBe(true); expect(existsSync(join(target, '.claude', 'skills', 'archon', 'guides', 'setup.md'))).toBe( @@ -425,11 +425,11 @@ CODEX_ACCOUNT_ID=account1 ).toBe(true); }); - it('should write non-empty content to skill files', () => { + it('should write non-empty content to skill files', async () => { const target = join(TEST_DIR, 'skill-target-content'); mkdirSync(target, { recursive: true }); - copyArchonSkill(target); + await copyArchonSkill(target); const content = readFileSync( join(target, '.claude', 'skills', 'archon', 'SKILL.md'), @@ -439,23 +439,23 @@ CODEX_ACCOUNT_ID=account1 expect(content).toContain('archon'); }); - it('should overwrite existing skill files', () => { + it('should overwrite existing skill files', async () => { const target = join(TEST_DIR, 'skill-target-overwrite'); const skillDir = join(target, '.claude', 'skills', 'archon'); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, 'SKILL.md'), 'old content'); - copyArchonSkill(target); + await copyArchonSkill(target); const content = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8'); expect(content).not.toBe('old content'); }); - it('should create skill files even when target directory does not exist', () => { + it('should create skill files even when target directory does not exist', async () => { const target = join(TEST_DIR, 'non-existent-parent', 'skill-target-new'); // Do NOT pre-create target — copyArchonSkill must handle it - copyArchonSkill(target); + await copyArchonSkill(target); expect(existsSync(join(target, '.claude', 'skills', 'archon', 'SKILL.md'))).toBe(true); }); diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 2160a99d8a..b1405d6298 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -35,7 +35,6 @@ import { import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, chmodSync } from 'fs'; import { parse as parseDotenv } from 'dotenv'; import { join, dirname } from 'path'; -import { BUNDLED_SKILL_FILES } from '../bundled-skill'; import { homedir } from 'os'; import { randomBytes } from 'crypto'; import { spawn, execSync, type ChildProcess } from 'child_process'; @@ -1448,8 +1447,18 @@ export function writeScopedEnv( * Copy the bundled Archon skill files to /.claude/skills/archon/ * * Always overwrites existing files to ensure the latest skill version is installed. + * + * The `bundled-skill` module is dynamically imported here so that its 18 top-level + * `import … with { type: 'text' }` statements only execute when this function is + * actually called. Compiled binaries (`bun build --compile`) still statically + * analyze the literal-string `import()` and embed the chunk; linked-source + * installs (`bun link`) don't touch the source skill files unless the user runs + * `archon setup`. Without this indirection, every `archon` invocation — + * including `archon --help` — fails at module load when the source skill files + * are missing from disk. */ -export function copyArchonSkill(targetPath: string): void { +export async function copyArchonSkill(targetPath: string): Promise { + const { BUNDLED_SKILL_FILES } = await import('../bundled-skill'); const skillRoot = join(targetPath, '.claude', 'skills', 'archon'); for (const [relativePath, content] of Object.entries(BUNDLED_SKILL_FILES)) { const dest = join(skillRoot, relativePath); @@ -1841,7 +1850,7 @@ export async function setupCommand(options: SetupOptions): Promise { const skillTarget = skillTargetRaw; s.start('Installing Archon skill...'); try { - copyArchonSkill(skillTarget); + await copyArchonSkill(skillTarget); } catch (err) { s.stop('Archon skill installation failed'); cancel(`Could not install skill: ${(err as NodeJS.ErrnoException).message}`); diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 27b9964835..ed92c58cbe 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -544,6 +544,9 @@ export async function handleMessage( try { getLog().debug({ conversationId }, 'orchestrator_message_received'); + // React with 👀 to acknowledge message received + await platform.addReaction?.(conversationId, 'eyes'); + // 1. Get/create conversation and inherit thread context let conversation = await db.getOrCreateConversation( platform.getPlatformType(), @@ -833,6 +836,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 platform.addReaction?.(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()); @@ -902,9 +908,24 @@ export async function handleMessage( } getLog().debug({ conversationId }, 'orchestrator_message_completed'); + + // Remove intermediate reactions before adding final status + await platform.removeReaction?.(conversationId, 'eyes'); + await platform.removeReaction?.(conversationId, 'arrows_counterclockwise'); + + // React with ✅ on success + await platform.addReaction?.(conversationId, 'white_check_mark'); } catch (error) { const err = toError(error); getLog().error({ err, conversationId }, 'orchestrator_message_failed'); + + // Remove intermediate reactions before adding final status + await platform.removeReaction?.(conversationId, 'eyes'); + await platform.removeReaction?.(conversationId, 'arrows_counterclockwise'); + + // React with ❌ on failure + await platform.addReaction?.(conversationId, 'x'); + const userMessage = classifyAndFormatError(err); try { await platform.sendMessage(conversationId, userMessage); 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; } /** From 79da658c5e24db44591a374a81a072bcb2a704ed Mon Sep 17 00:00:00 2001 From: kiranchilledout Date: Wed, 29 Apr 2026 00:25:32 +0200 Subject: [PATCH 2/3] fix: make reaction updates non-blocking with try/finally --- .../src/orchestrator/orchestrator-agent.ts | 72 +++++++++++++++---- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index ed92c58cbe..948099daf0 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. + */ +async function safeAddReaction( + platform: IPlatformAdapter, + conversationId: string, + reaction: string +): Promise { + try { + await platform.addReaction?.(conversationId, reaction); + } catch (error) { + getLog().warn({ err: error, conversationId, reaction }, 'reaction_add_failed'); + } +} + +/** + * Safely remove a reaction from a message. + * Errors are logged but not thrown to avoid breaking core message handling. + */ +async function safeRemoveReaction( + platform: IPlatformAdapter, + conversationId: string, + reaction: string +): Promise { + try { + await platform.removeReaction?.(conversationId, reaction); + } catch (error) { + getLog().warn({ err: error, conversationId, reaction }, 'reaction_remove_failed'); + } +} + // ─── Types ────────────────────────────────────────────────────────────────── export interface WorkflowInvocation { @@ -545,7 +579,7 @@ export async function handleMessage( getLog().debug({ conversationId }, 'orchestrator_message_received'); // React with 👀 to acknowledge message received - await platform.addReaction?.(conversationId, 'eyes'); + await safeAddReaction(platform, conversationId, 'eyes'); // 1. Get/create conversation and inherit thread context let conversation = await db.getOrCreateConversation( @@ -837,7 +871,7 @@ export async function handleMessage( getLog().debug({ assistantType: conversation.ai_assistant_type }, 'sending_to_ai'); // React with 🔄 when work begins - await platform.addReaction?.(conversationId, 'arrows_counterclockwise'); + 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). @@ -909,22 +943,32 @@ export async function handleMessage( getLog().debug({ conversationId }, 'orchestrator_message_completed'); - // Remove intermediate reactions before adding final status - await platform.removeReaction?.(conversationId, 'eyes'); - await platform.removeReaction?.(conversationId, 'arrows_counterclockwise'); - - // React with ✅ on success - await platform.addReaction?.(conversationId, 'white_check_mark'); + // 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'); + } finally { + // No additional cleanup needed + } } catch (error) { const err = toError(error); getLog().error({ err, conversationId }, 'orchestrator_message_failed'); - // Remove intermediate reactions before adding final status - await platform.removeReaction?.(conversationId, 'eyes'); - await platform.removeReaction?.(conversationId, 'arrows_counterclockwise'); - - // React with ❌ on failure - await platform.addReaction?.(conversationId, 'x'); + // 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'); + } finally { + // No additional cleanup needed + } const userMessage = classifyAndFormatError(err); try { From 1f5397cf992bdf30bf395cb8d90098c088471f53 Mon Sep 17 00:00:00 2001 From: Kiran Raj Date: Wed, 29 Apr 2026 14:07:35 +0200 Subject: [PATCH 3/3] Fixed Comments by Rasmus --- CHANGELOG.md | 4 ++ packages/adapters/src/chat/slack/adapter.ts | 4 +- packages/core/package.json | 2 +- .../src/orchestrator/orchestrator-agent.ts | 16 ++--- .../src/orchestrator/safe-reaction.test.ts | 72 +++++++++++++++++++ .../src/content/docs/adapters/slack.md | 14 ++++ 6 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/orchestrator/safe-reaction.test.ts 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.ts b/packages/adapters/src/chat/slack/adapter.ts index 15b1d6e997..29ff886328 100644 --- a/packages/adapters/src/chat/slack/adapter.ts +++ b/packages/adapters/src/chat/slack/adapter.ts @@ -200,7 +200,7 @@ export class SlackAdapter implements IPlatformAdapter { /** * Add a reaction (emoji) to a message - * @param conversationId - Format: "channel:timestamp" + * @param conversationId - Slack: "channel:timestamp" * @param reaction - Emoji shortname (e.g., "eyes", "white_check_mark", "x") */ async addReaction(conversationId: string, reaction: string): Promise { @@ -226,7 +226,7 @@ export class SlackAdapter implements IPlatformAdapter { /** * Remove a reaction from a message - * @param conversationId - Format: "channel:timestamp" + * @param conversationId - Slack: "channel:timestamp" * @param reaction - Emoji shortname to remove (e.g., "eyes", "arrows_counterclockwise") */ async removeReaction(conversationId: string, reaction: string): Promise { 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 e4cadd9b51..a76c84ab2f 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -75,7 +75,7 @@ const MAX_BATCH_TOTAL_CHUNKS = 200; * Safely add a reaction to a message. * Errors are logged but not thrown to avoid breaking core message handling. */ -async function safeAddReaction( +export async function safeAddReaction( platform: IPlatformAdapter, conversationId: string, reaction: string @@ -83,7 +83,7 @@ async function safeAddReaction( try { await platform.addReaction?.(conversationId, reaction); } catch (error) { - getLog().warn({ err: error, conversationId, reaction }, 'reaction_add_failed'); + getLog().warn({ err: error, conversationId, reaction }, 'orchestrator.reaction_add_failed'); } } @@ -91,7 +91,7 @@ async function safeAddReaction( * Safely remove a reaction from a message. * Errors are logged but not thrown to avoid breaking core message handling. */ -async function safeRemoveReaction( +export async function safeRemoveReaction( platform: IPlatformAdapter, conversationId: string, reaction: string @@ -99,7 +99,7 @@ async function safeRemoveReaction( try { await platform.removeReaction?.(conversationId, reaction); } catch (error) { - getLog().warn({ err: error, conversationId, reaction }, 'reaction_remove_failed'); + getLog().warn({ err: error, conversationId, reaction }, 'orchestrator.reaction_remove_failed'); } } @@ -954,8 +954,8 @@ export async function handleMessage( // React with ✅ on success await safeAddReaction(platform, conversationId, 'white_check_mark'); - } finally { - // No additional cleanup needed + } catch { + // Silently ignore reaction errors } } catch (error) { const err = toError(error); @@ -969,8 +969,8 @@ export async function handleMessage( // React with ❌ on failure await safeAddReaction(platform, conversationId, 'x'); - } finally { - // No additional cleanup needed + } catch { + // Silently ignore reaction errors } const userMessage = classifyAndFormatError(err); 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/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