From 8a83a6af5ca1ca825625b0c3b69816bbbd1633a9 Mon Sep 17 00:00:00 2001 From: agentclear Date: Fri, 29 May 2026 14:13:02 -0700 Subject: [PATCH 1/4] feat(slack): follow all replies in threads Cyrus is bound to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After an initial @mention binds a Slack thread, plain follow-up messages in that thread are now fed into the same agent session — no re-mention needed. - SlackEventTransport accepts "message" events, drops bot/own messages, subtype events, and non-threaded messages, and de-dupes the app_mention + message double-delivery on (channel, ts). - ChatPlatformAdapter gains optional isSessionInitiatingEvent; ChatSessionHandler refuses to start a new session from a non-initiating event, so a plain reply only continues an already-bound thread. - SlackChatAdapter marks app_mention as initiating, message as follow-up. - Slack setup manifest subscribes to message.channels/groups/mpim/im. CYPACK-1267 --- CHANGELOG.md | 1 + .../edge-worker/src/ChatSessionHandler.ts | 25 +++ packages/edge-worker/src/SlackChatAdapter.ts | 10 ++ .../edge-worker/test/chat-sessions.test.ts | 73 +++++++++ .../src/SlackEventTransport.ts | 99 +++++++++++- .../src/SlackMessageTranslator.ts | 11 +- packages/slack-event-transport/src/index.ts | 2 + packages/slack-event-transport/src/types.ts | 58 ++++++- .../test/SlackEventTransport.test.ts | 144 +++++++++++++++++- .../test/SlackMessageTranslator.test.ts | 44 +++++- .../slack-event-transport/test/fixtures.ts | 22 +++ skills/cyrus-setup-slack/SKILL.md | 6 +- 12 files changed, 477 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba0564c84..8315885ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Updated `@anthropic-ai/claude-agent-sdk` from 0.3.154 to 0.3.156 and `@anthropic-ai/sdk` from 0.100.0 to 0.100.1. See the [SDK changelog](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md) for details. ([CYPACK-1265](https://linear.app/ceedar/issue/CYPACK-1265), [#1270](https://github.com/cyrusagents/cyrus/pull/1270)) ### Added +- Cyrus now follows along in Slack threads it's been pulled into: once you @mention it in a thread, every later reply in that thread is fed to the same session automatically — no need to re-mention it each time. Replies that do @mention it still work as before, and Cyrus ignores its own messages, edits, and plain channel chatter outside threads it's part of. **Existing Slack apps must add the `message.channels`, `message.groups`, `message.mpim`, and `message.im` bot events in their Slack app's Event Subscriptions for this to take effect.** ([CYPACK-1267](https://linear.app/ceedar/issue/CYPACK-1267)) - A repository can now ship its own skills: any skill directories under `/.claude/skills/` are automatically discovered and made available to the agent whenever Cyrus works in that repo — for single-repo issues, multi-repo issues (skills from every participating repo are combined), and GitHub/GitLab mentions alike. ([CYPACK-1261](https://linear.app/ceedar/issue/CYPACK-1261), [#1268](https://github.com/cyrusagents/cyrus/pull/1268)) ## [0.2.60] - 2026-05-28 diff --git a/packages/edge-worker/src/ChatSessionHandler.ts b/packages/edge-worker/src/ChatSessionHandler.ts index 96f1ca227..bcbd7ce49 100644 --- a/packages/edge-worker/src/ChatSessionHandler.ts +++ b/packages/edge-worker/src/ChatSessionHandler.ts @@ -28,6 +28,17 @@ export interface ChatPlatformAdapter { /** Extract the user's task text from the raw event */ extractTaskInstructions(event: TEvent): string; + /** + * Whether this event is allowed to *start* a brand-new session for its + * thread. Events that may only continue an already-bound thread (e.g. a + * plain Slack message that isn't an @mention) return false, so the handler + * ignores them when no session exists yet. + * + * Optional — when omitted, every event is treated as session-initiating + * (the behaviour for platforms where every event is an explicit invocation). + */ + isSessionInitiatingEvent?(event: TEvent): boolean; + /** Derive a unique thread key for session tracking (e.g., "C123:1704110400.000100") */ getThreadKey(event: TEvent): string; @@ -199,6 +210,20 @@ export class ChatSessionHandler { ); } + // No session exists for this thread. Only events explicitly allowed to + // start a session may do so — e.g. a Slack @mention. A plain follow-up + // message in an unbound thread must be ignored, otherwise every message + // in any channel Cyrus can see would spin up a session. + if ( + !existingSessionId && + this.adapter.isSessionInitiatingEvent?.(event) === false + ) { + this.logger.info( + `Ignoring non-initiating ${this.adapter.platformName} event for unbound thread ${threadKey}`, + ); + return; + } + // Create an empty workspace directory for this thread const workspace = await this.createWorkspace(threadKey); if (!workspace) { diff --git a/packages/edge-worker/src/SlackChatAdapter.ts b/packages/edge-worker/src/SlackChatAdapter.ts index a34ca4568..fd10b0a26 100644 --- a/packages/edge-worker/src/SlackChatAdapter.ts +++ b/packages/edge-worker/src/SlackChatAdapter.ts @@ -74,6 +74,16 @@ export class SlackChatAdapter ); } + /** + * Only an explicit @mention may start a new session. Plain `message` events + * are follow-ups: they continue a thread Cyrus is already bound to, but + * never spin up a session on their own (otherwise every message in a watched + * channel would start one). + */ + isSessionInitiatingEvent(event: SlackWebhookEvent): boolean { + return event.eventType === "app_mention"; + } + getThreadKey(event: SlackWebhookEvent): string { const threadTs = event.payload.thread_ts || event.payload.ts; return `${event.payload.channel}:${threadTs}`; diff --git a/packages/edge-worker/test/chat-sessions.test.ts b/packages/edge-worker/test/chat-sessions.test.ts index 17e595817..ba91ac5ed 100644 --- a/packages/edge-worker/test/chat-sessions.test.ts +++ b/packages/edge-worker/test/chat-sessions.test.ts @@ -150,6 +150,79 @@ describe("ChatSessionHandler chat session permissions", () => { }); }); +describe("ChatSessionHandler session-initiation gate", () => { + function buildHandler(adapter: ChatPlatformAdapter) { + const createRunner = vi.fn( + () => + ({ + supportsStreamingInput: false, + start: vi.fn().mockResolvedValue({ sessionId: "session-1" }), + stop: vi.fn(), + isRunning: vi.fn().mockReturnValue(false), + isStreaming: vi.fn().mockReturnValue(false), + addStreamMessage: vi.fn(), + getMessages: vi.fn().mockReturnValue([]), + }) as any, + ); + const handler = new ChatSessionHandler(adapter, { + cyrusHome: TEST_CYRUS_CHAT, + chatRepositoryProvider: createStaticProvider([]), + runnerConfigBuilder: createMockRunnerConfigBuilder(), + createRunner, + onWebhookStart: vi.fn(), + onWebhookEnd: vi.fn(), + onStateChange: vi.fn().mockResolvedValue(undefined), + onClaudeError: vi.fn(), + }); + return { handler, createRunner }; + } + + it("ignores a non-initiating event when no session exists for the thread", async () => { + const adapter: ChatPlatformAdapter = new TestChatAdapter( + "unbound-thread", + ); + // Mark this event as a follow-up that must not start a session. + adapter.isSessionInitiatingEvent = () => false; + + const { handler, createRunner } = buildHandler(adapter); + await handler.handleEvent({ + eventId: "follow-up", + threadKey: "unbound-thread", + } as any); + + expect(createRunner).not.toHaveBeenCalled(); + expect(handler.listThreads()).toHaveLength(0); + }); + + it("starts a session for an initiating event", async () => { + const adapter: ChatPlatformAdapter = new TestChatAdapter( + "bound-thread", + ); + adapter.isSessionInitiatingEvent = () => true; + + const { handler, createRunner } = buildHandler(adapter); + await handler.handleEvent({ + eventId: "mention", + threadKey: "bound-thread", + } as any); + + expect(createRunner).toHaveBeenCalledTimes(1); + expect(handler.listThreads()).toHaveLength(1); + }); +}); + +describe("SlackChatAdapter session initiation", () => { + it("treats app_mention as session-initiating and message as a follow-up", () => { + const adapter = new SlackChatAdapter(createStaticProvider([])); + expect( + adapter.isSessionInitiatingEvent({ eventType: "app_mention" } as any), + ).toBe(true); + expect( + adapter.isSessionInitiatingEvent({ eventType: "message" } as any), + ).toBe(false); + }); +}); + describe("SlackChatAdapter system prompt", () => { it("includes configured repository context and git pull instructions", () => { const repositoryPaths = ["/repo/chat-one", "/repo/chat-two"]; diff --git a/packages/slack-event-transport/src/SlackEventTransport.ts b/packages/slack-event-transport/src/SlackEventTransport.ts index ce41aa0e8..1b19a125d 100644 --- a/packages/slack-event-transport/src/SlackEventTransport.ts +++ b/packages/slack-event-transport/src/SlackEventTransport.ts @@ -8,6 +8,7 @@ import type { SlackEventEnvelope, SlackEventTransportConfig, SlackEventTransportEvents, + SlackMessageEvent, SlackVerificationMode, SlackWebhookEvent, } from "./types.js"; @@ -33,13 +34,28 @@ export declare interface SlackEventTransport { * and verifies incoming webhooks using Bearer token authentication. * * Supported Slack event types: - * - app_mention: When the bot is mentioned with @ in a channel or thread + * - app_mention: When the bot is mentioned with @ in a channel or thread. + * Always emitted — starts or resumes a session for the thread. + * - message: A plain message in a channel/thread the bot can see. Emitted only + * for threaded replies that aren't the bot's own message and aren't a + * subtype event (edits, joins, etc.). Whether it actually does anything is + * decided downstream (ChatSessionHandler only continues already-bound + * threads). Slack delivers both an `app_mention` AND a `message` event for a + * message that mentions the bot, so identical `(channel, ts)` pairs are + * de-duplicated here to avoid double-prompting. */ export class SlackEventTransport extends EventEmitter { private config: SlackEventTransportConfig; private logger: ILogger; private messageTranslator: SlackMessageTranslator; private translationContext: TranslationContext; + /** + * Recently emitted `channel:ts` keys, used to collapse Slack's + * double-delivery of app_mention + message for the same underlying message. + * Maps key → epoch ms first seen; pruned by TTL. + */ + private recentMessageKeys: Map = new Map(); + private static readonly DEDUP_TTL_MS = 10 * 60 * 1000; constructor( config: SlackEventTransportConfig, @@ -271,21 +287,47 @@ export class SlackEventTransport extends EventEmitter { const event = envelope.event; - if (!event || event.type !== "app_mention") { + // Slack sends many event types at runtime; the envelope type only models + // the two we handle, so widen to string for the membership check. + const eventType = event?.type as string | undefined; + if (eventType !== "app_mention" && eventType !== "message") { this.logger.debug( - `Ignoring unsupported event type: ${event?.type ?? "unknown"}`, + `Ignoring unsupported event type: ${eventType ?? "unknown"}`, ); reply.code(200).send({ success: true, ignored: true }); return; } + // `message` events fire for every message in every channel the bot can + // see, so apply cheap structural filters before doing any work. Anything + // that gets through here is a candidate follow-up prompt; the binding + // check (is this thread actually bound to Cyrus?) happens downstream. + if (event.type === "message" && !this.shouldEmitMessageEvent(event)) { + reply.code(200).send({ success: true, ignored: true }); + return; + } + + // Slack delivers both an app_mention and a message event for a single + // message that mentions the bot. De-duplicate on (channel, ts) so the + // thread only gets prompted once. The first event to arrive wins; both + // carry identical text. + const dedupKey = `${event.channel}:${event.ts}`; + if (this.isDuplicateMessage(dedupKey)) { + this.logger.debug( + `Ignoring duplicate Slack event for ${dedupKey} (already processed)`, + ); + reply.code(200).send({ success: true, ignored: true }); + return; + } + this.rememberMessage(dedupKey); + // Token may be undefined during startup transitions (e.g. switching runtimes) // when the env update hasn't been processed yet. Downstream consumers // (SlackChatAdapter) fall back to process.env.SLACK_BOT_TOKEN at usage time. const slackBotToken = this.getSlackBotToken(); const webhookEvent: SlackWebhookEvent = { - eventType: "app_mention", + eventType: event.type, eventId: envelope.event_id, payload: event, slackBotToken, @@ -293,7 +335,7 @@ export class SlackEventTransport extends EventEmitter { }; this.logger.info( - `Received app_mention webhook (event: ${envelope.event_id}, channel: ${event.channel})`, + `Received ${event.type} webhook (event: ${envelope.event_id}, channel: ${event.channel})`, ); // Emit "event" for transport-level listeners @@ -305,6 +347,53 @@ export class SlackEventTransport extends EventEmitter { reply.code(200).send({ success: true }); } + /** + * Decide whether a `message` event is a candidate follow-up prompt. + * + * Drops the bot's own messages (which would otherwise loop), edited/deleted + * and other subtype events, and top-level (non-threaded) messages — only a + * threaded reply can belong to a thread Cyrus is already bound to. + */ + private shouldEmitMessageEvent(event: SlackMessageEvent): boolean { + if (event.bot_id) { + this.logger.debug( + `Ignoring Slack message from bot ${event.bot_id} (channel ${event.channel})`, + ); + return false; + } + if (event.subtype) { + this.logger.debug( + `Ignoring Slack message with subtype "${event.subtype}" (channel ${event.channel})`, + ); + return false; + } + if (!event.thread_ts) { + this.logger.debug( + `Ignoring non-threaded Slack message (channel ${event.channel})`, + ); + return false; + } + return true; + } + + private isDuplicateMessage(key: string): boolean { + this.pruneRecentMessageKeys(); + return this.recentMessageKeys.has(key); + } + + private rememberMessage(key: string): void { + this.recentMessageKeys.set(key, Date.now()); + } + + private pruneRecentMessageKeys(): void { + const now = Date.now(); + for (const [key, seenAt] of this.recentMessageKeys) { + if (now - seenAt > SlackEventTransport.DEDUP_TTL_MS) { + this.recentMessageKeys.delete(key); + } + } + } + /** * Translate and emit an internal message from a webhook event. * Only emits if translation succeeds; logs debug message on failure. diff --git a/packages/slack-event-transport/src/SlackMessageTranslator.ts b/packages/slack-event-transport/src/SlackMessageTranslator.ts index 03e1c126c..dfc9d373a 100644 --- a/packages/slack-event-transport/src/SlackMessageTranslator.ts +++ b/packages/slack-event-transport/src/SlackMessageTranslator.ts @@ -53,7 +53,7 @@ export class SlackMessageTranslator return ( typeof e.eventType === "string" && - e.eventType === "app_mention" && + (e.eventType === "app_mention" || e.eventType === "message") && typeof e.eventId === "string" && e.payload !== null && typeof e.payload === "object" @@ -75,6 +75,13 @@ export class SlackMessageTranslator return this.translateAppMention(event, context); } + // A plain `message` event is always a follow-up in an existing thread — + // it can only reach here for a thread Cyrus is already bound to, so it + // maps to a user prompt rather than a session start. + if (event.eventType === "message") { + return this.translateAppMentionAsUserPrompt(event, context); + } + return { success: false, reason: `Unsupported Slack event type: ${event.eventType}`, @@ -90,7 +97,7 @@ export class SlackMessageTranslator event: SlackWebhookEvent, context?: TranslationContext, ): TranslationResult { - if (event.eventType === "app_mention") { + if (event.eventType === "app_mention" || event.eventType === "message") { return this.translateAppMentionAsUserPrompt(event, context); } diff --git a/packages/slack-event-transport/src/index.ts b/packages/slack-event-transport/src/index.ts index df5655d94..1ff0b96b0 100644 --- a/packages/slack-event-transport/src/index.ts +++ b/packages/slack-event-transport/src/index.ts @@ -15,9 +15,11 @@ export type { SlackAppMentionEvent, SlackChannel, SlackEventEnvelope, + SlackEventPayload, SlackEventTransportConfig, SlackEventTransportEvents, SlackEventType, + SlackMessageEvent, SlackUser, SlackVerificationMode, SlackWebhookEvent, diff --git a/packages/slack-event-transport/src/types.ts b/packages/slack-event-transport/src/types.ts index 7dd4b09e7..0a8560d7c 100644 --- a/packages/slack-event-transport/src/types.ts +++ b/packages/slack-event-transport/src/types.ts @@ -40,12 +40,12 @@ export interface SlackEventTransportEvents { * Processed Slack webhook event that is emitted to listeners */ export interface SlackWebhookEvent { - /** The Slack event type (e.g., 'app_mention') */ + /** The Slack event type (e.g., 'app_mention', 'message') */ eventType: SlackEventType; /** Unique event ID from Slack */ eventId: string; /** The full Slack event payload */ - payload: SlackAppMentionEvent; + payload: SlackEventPayload; /** Slack Bot token for API access */ slackBotToken?: string; /** Workspace/team ID */ @@ -53,9 +53,23 @@ export interface SlackWebhookEvent { } /** - * Supported Slack event types + * Supported Slack event types. + * + * - `app_mention`: an explicit @mention of the bot — always starts (or resumes) + * a session for the thread. + * - `message`: a plain message in a channel/thread the bot can see. Only acted + * on as a follow-up prompt for a thread the bot is already bound to; never + * starts a new session (see ChatSessionHandler.isSessionInitiatingEvent). */ -export type SlackEventType = "app_mention"; +export type SlackEventType = "app_mention" | "message"; + +/** + * Union of the Slack event payloads this transport understands. + * + * Both members share the `user`/`text`/`ts`/`channel`/`thread_ts`/`event_ts` + * fields, so downstream consumers can read those without narrowing. + */ +export type SlackEventPayload = SlackAppMentionEvent | SlackMessageEvent; // ============================================================================ // Slack Event API Payload Types @@ -106,6 +120,40 @@ export interface SlackAppMentionEvent { event_ts: string; } +/** + * Slack message event payload + * + * Fired for plain messages in channels/threads the bot can see (requires the + * `message.*` bot event subscriptions and matching `*:history` scopes). Cyrus + * only acts on threaded replies (`thread_ts` present) in threads it is already + * bound to; the gating lives in SlackEventTransport (cheap structural filters) + * and ChatSessionHandler (binding check). + * @see https://api.slack.com/events/message + */ +export interface SlackMessageEvent { + /** Event type - always "message" */ + type: "message"; + /** + * Message subtype (e.g. "message_changed", "channel_join", "bot_message"). + * Plain user messages have no subtype; we ignore anything with one. + */ + subtype?: string; + /** ID of the bot that posted this message, if any. Used to ignore bot/own messages. */ + bot_id?: string; + /** User ID who sent the message */ + user: string; + /** The message text */ + text: string; + /** Message timestamp (unique ID within channel) */ + ts: string; + /** Channel ID where the message occurred */ + channel: string; + /** Thread timestamp - present if this is a threaded reply */ + thread_ts?: string; + /** Event timestamp */ + event_ts: string; +} + /** * Slack Event API wrapper envelope * This is the outer payload that wraps the actual event. @@ -119,7 +167,7 @@ export interface SlackEventEnvelope { /** API app ID */ api_app_id: string; /** The actual event data */ - event: SlackAppMentionEvent; + event: SlackEventPayload; /** Type of envelope - "event_callback" for events */ type: "event_callback" | "url_verification"; /** Unique event ID */ diff --git a/packages/slack-event-transport/test/SlackEventTransport.test.ts b/packages/slack-event-transport/test/SlackEventTransport.test.ts index 5dc943cb8..9a3a2fa24 100644 --- a/packages/slack-event-transport/test/SlackEventTransport.test.ts +++ b/packages/slack-event-transport/test/SlackEventTransport.test.ts @@ -5,6 +5,7 @@ import type { SlackEventTransportConfig } from "../src/types.js"; import { testEventEnvelope, testThreadedEventEnvelope, + testThreadedMessageEnvelope, testUrlVerificationEnvelope, } from "./fixtures.js"; @@ -311,13 +312,13 @@ describe("SlackEventTransport", () => { expect(eventListener).not.toHaveBeenCalled(); }); - it("ignores events with non-app_mention type", async () => { + it("ignores events with an unsupported type", async () => { const eventListener = vi.fn(); transport.on("event", eventListener); const envelope = { ...testEventEnvelope, - event: { ...testEventEnvelope.event, type: "message" }, + event: { ...testEventEnvelope.event, type: "reaction_added" }, }; const request = createMockRequest(envelope, { authorization: `Bearer ${testSecret}`, @@ -334,6 +335,145 @@ describe("SlackEventTransport", () => { }); expect(eventListener).not.toHaveBeenCalled(); }); + + it("emits threaded message events as follow-ups", async () => { + const eventListener = vi.fn(); + transport.on("event", eventListener); + + const request = createMockRequest(testThreadedMessageEnvelope, { + authorization: `Bearer ${testSecret}`, + }); + const reply = createMockReply(); + + const handler = mockFastify.routes["/slack-webhook"]!; + await handler(request, reply); + + expect(reply.code).toHaveBeenCalledWith(200); + expect(reply.send).toHaveBeenCalledWith({ success: true }); + expect(eventListener).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "message", + eventId: "Ev0004", + payload: expect.objectContaining({ + thread_ts: "1704110400.000100", + }), + }), + ); + }); + + it("ignores non-threaded message events", async () => { + const eventListener = vi.fn(); + transport.on("event", eventListener); + + const envelope = { + ...testThreadedMessageEnvelope, + event: { ...testThreadedMessageEnvelope.event, thread_ts: undefined }, + }; + const request = createMockRequest(envelope, { + authorization: `Bearer ${testSecret}`, + }); + const reply = createMockReply(); + + const handler = mockFastify.routes["/slack-webhook"]!; + await handler(request, reply); + + expect(reply.code).toHaveBeenCalledWith(200); + expect(reply.send).toHaveBeenCalledWith({ + success: true, + ignored: true, + }); + expect(eventListener).not.toHaveBeenCalled(); + }); + + it("ignores message events from a bot (loop prevention)", async () => { + const eventListener = vi.fn(); + transport.on("event", eventListener); + + const envelope = { + ...testThreadedMessageEnvelope, + event: { ...testThreadedMessageEnvelope.event, bot_id: "B0BOT" }, + }; + const request = createMockRequest(envelope, { + authorization: `Bearer ${testSecret}`, + }); + const reply = createMockReply(); + + const handler = mockFastify.routes["/slack-webhook"]!; + await handler(request, reply); + + expect(reply.send).toHaveBeenCalledWith({ + success: true, + ignored: true, + }); + expect(eventListener).not.toHaveBeenCalled(); + }); + + it("ignores message events with a subtype (edits, joins, etc.)", async () => { + const eventListener = vi.fn(); + transport.on("event", eventListener); + + const envelope = { + ...testThreadedMessageEnvelope, + event: { + ...testThreadedMessageEnvelope.event, + subtype: "message_changed", + }, + }; + const request = createMockRequest(envelope, { + authorization: `Bearer ${testSecret}`, + }); + const reply = createMockReply(); + + const handler = mockFastify.routes["/slack-webhook"]!; + await handler(request, reply); + + expect(reply.send).toHaveBeenCalledWith({ + success: true, + ignored: true, + }); + expect(eventListener).not.toHaveBeenCalled(); + }); + + it("de-duplicates app_mention + message delivery for the same message", async () => { + const eventListener = vi.fn(); + transport.on("event", eventListener); + + const handler = mockFastify.routes["/slack-webhook"]!; + + // app_mention arrives first for ts 1704110500.000200 + await handler( + createMockRequest(testThreadedEventEnvelope, { + authorization: `Bearer ${testSecret}`, + }), + createMockReply(), + ); + + // Slack also delivers the matching message event (same channel:ts) + const duplicateMessageEnvelope = { + ...testThreadedMessageEnvelope, + event: { + ...testThreadedMessageEnvelope.event, + ts: "1704110500.000200", + }, + }; + const dupReply = createMockReply(); + await handler( + createMockRequest(duplicateMessageEnvelope, { + authorization: `Bearer ${testSecret}`, + }), + dupReply, + ); + + // Only the first (app_mention) event is emitted; the message is dropped. + expect(eventListener).toHaveBeenCalledTimes(1); + expect(eventListener).toHaveBeenCalledWith( + expect.objectContaining({ eventType: "app_mention" }), + ); + expect(dupReply.send).toHaveBeenCalledWith({ + success: true, + ignored: true, + }); + }); }); describe("error handling", () => { diff --git a/packages/slack-event-transport/test/SlackMessageTranslator.test.ts b/packages/slack-event-transport/test/SlackMessageTranslator.test.ts index 173f17630..0e21e724f 100644 --- a/packages/slack-event-transport/test/SlackMessageTranslator.test.ts +++ b/packages/slack-event-transport/test/SlackMessageTranslator.test.ts @@ -32,12 +32,21 @@ describe("SlackMessageTranslator", () => { expect(translator.canTranslate(rest)).toBe(false); }); - it("returns false for unsupported eventType", () => { + it("returns true for message webhook events", () => { expect( translator.canTranslate({ ...testWebhookEvent, eventType: "message", }), + ).toBe(true); + }); + + it("returns false for unsupported eventType", () => { + expect( + translator.canTranslate({ + ...testWebhookEvent, + eventType: "reaction_added", + }), ).toBe(false); }); @@ -193,13 +202,28 @@ describe("SlackMessageTranslator", () => { expect(msg.title.endsWith("...")).toBe(true); }); - it("returns failure for unsupported event types", () => { + it("translates message events as UserPromptMessage", () => { const event = { ...testWebhookEvent, eventType: "message" as SlackWebhookEvent["eventType"], }; const result = translator.translate(event); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.message.source).toBe("slack"); + expect(result.message.action).toBe("user_prompt"); + }); + + it("returns failure for unsupported event types", () => { + const event = { + ...testWebhookEvent, + eventType: + "reaction_added" as unknown as SlackWebhookEvent["eventType"], + }; + const result = translator.translate(event); + expect(result.success).toBe(false); if (result.success) return; @@ -239,13 +263,27 @@ describe("SlackMessageTranslator", () => { expect(result.message.sessionKey).toBe("C9876543210:1704110400.000100"); }); - it("returns failure for unsupported event types", () => { + it("translates message events as UserPromptMessage", () => { const event = { ...testWebhookEvent, eventType: "message" as SlackWebhookEvent["eventType"], }; const result = translator.translateAsUserPrompt(event); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.message.action).toBe("user_prompt"); + }); + + it("returns failure for unsupported event types", () => { + const event = { + ...testWebhookEvent, + eventType: + "reaction_added" as unknown as SlackWebhookEvent["eventType"], + }; + const result = translator.translateAsUserPrompt(event); + expect(result.success).toBe(false); if (result.success) return; diff --git a/packages/slack-event-transport/test/fixtures.ts b/packages/slack-event-transport/test/fixtures.ts index a5ec57379..817c6039a 100644 --- a/packages/slack-event-transport/test/fixtures.ts +++ b/packages/slack-event-transport/test/fixtures.ts @@ -4,6 +4,7 @@ import type { SlackAppMentionEvent, SlackEventEnvelope, + SlackMessageEvent, SlackWebhookEvent, } from "../src/types.js"; @@ -46,6 +47,27 @@ export const testThreadedEventEnvelope: SlackEventEnvelope = { event_time: 1704110500, }; +/** A plain follow-up reply in the thread started by testAppMentionEvent. */ +export const testThreadedMessageEvent: SlackMessageEvent = { + type: "message", + user: "U1234567890", + text: "Actually, also bump the timeout to 30s", + ts: "1704110600.000300", + channel: "C9876543210", + thread_ts: "1704110400.000100", + event_ts: "1704110600.000300", +}; + +export const testThreadedMessageEnvelope: SlackEventEnvelope = { + token: "deprecated-token", + team_id: "T0001", + api_app_id: "A0001", + event: testThreadedMessageEvent, + type: "event_callback", + event_id: "Ev0004", + event_time: 1704110600, +}; + export const testUrlVerificationEnvelope: SlackEventEnvelope = { token: "deprecated-token", team_id: "T0001", diff --git a/skills/cyrus-setup-slack/SKILL.md b/skills/cyrus-setup-slack/SKILL.md index f140a0e4d..737fd9063 100644 --- a/skills/cyrus-setup-slack/SKILL.md +++ b/skills/cyrus-setup-slack/SKILL.md @@ -107,7 +107,11 @@ Construct the manifest, substituting ``, ``, and "request_url": "/slack-webhook", "bot_events": [ "app_mention", - "member_joined_channel" + "member_joined_channel", + "message.channels", + "message.groups", + "message.mpim", + "message.im" ] }, "org_deploy_enabled": false, From 53881f79d82b49e6d393230428566407bff7b049 Mon Sep 17 00:00:00 2001 From: agentclear Date: Fri, 29 May 2026 14:14:01 -0700 Subject: [PATCH 2/4] docs(changelog): link PR #1273 [CYPACK-1267] --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8315885ed..d1b68a695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file. - Updated `@anthropic-ai/claude-agent-sdk` from 0.3.154 to 0.3.156 and `@anthropic-ai/sdk` from 0.100.0 to 0.100.1. See the [SDK changelog](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md) for details. ([CYPACK-1265](https://linear.app/ceedar/issue/CYPACK-1265), [#1270](https://github.com/cyrusagents/cyrus/pull/1270)) ### Added -- Cyrus now follows along in Slack threads it's been pulled into: once you @mention it in a thread, every later reply in that thread is fed to the same session automatically — no need to re-mention it each time. Replies that do @mention it still work as before, and Cyrus ignores its own messages, edits, and plain channel chatter outside threads it's part of. **Existing Slack apps must add the `message.channels`, `message.groups`, `message.mpim`, and `message.im` bot events in their Slack app's Event Subscriptions for this to take effect.** ([CYPACK-1267](https://linear.app/ceedar/issue/CYPACK-1267)) +- Cyrus now follows along in Slack threads it's been pulled into: once you @mention it in a thread, every later reply in that thread is fed to the same session automatically — no need to re-mention it each time. Replies that do @mention it still work as before, and Cyrus ignores its own messages, edits, and plain channel chatter outside threads it's part of. **Existing Slack apps must add the `message.channels`, `message.groups`, `message.mpim`, and `message.im` bot events in their Slack app's Event Subscriptions for this to take effect.** ([CYPACK-1267](https://linear.app/ceedar/issue/CYPACK-1267), [#1273](https://github.com/cyrusagents/cyrus/pull/1273)) - A repository can now ship its own skills: any skill directories under `/.claude/skills/` are automatically discovered and made available to the agent whenever Cyrus works in that repo — for single-repo issues, multi-repo issues (skills from every participating repo are combined), and GitHub/GitLab mentions alike. ([CYPACK-1261](https://linear.app/ceedar/issue/CYPACK-1261), [#1268](https://github.com/cyrusagents/cyrus/pull/1268)) ## [0.2.60] - 2026-05-28 From 4bdcd921ea30ae2336d2b7a2200c801e0c44d6e4 Mon Sep 17 00:00:00 2001 From: agentclear Date: Mon, 1 Jun 2026 09:40:37 -0700 Subject: [PATCH 3/4] feat(slack): only reply in followed threads when relevant or addressed by name Now that Cyrus follows every message in a bound Slack thread, add a 'When to Respond' policy to the Slack system prompt: reply only when the message is a question it can answer or it's addressed by name; otherwise emit a <> sentinel that postReply suppresses, so Cyrus stays quiet on side conversation and chatter instead of replying to every message. CYPACK-1267 --- CHANGELOG.md | 2 +- packages/edge-worker/src/SlackChatAdapter.ts | 33 +++++++- .../edge-worker/test/chat-sessions.test.ts | 80 ++++++++++++++++++- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b68a695..028ff1e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file. - Updated `@anthropic-ai/claude-agent-sdk` from 0.3.154 to 0.3.156 and `@anthropic-ai/sdk` from 0.100.0 to 0.100.1. See the [SDK changelog](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md) for details. ([CYPACK-1265](https://linear.app/ceedar/issue/CYPACK-1265), [#1270](https://github.com/cyrusagents/cyrus/pull/1270)) ### Added -- Cyrus now follows along in Slack threads it's been pulled into: once you @mention it in a thread, every later reply in that thread is fed to the same session automatically — no need to re-mention it each time. Replies that do @mention it still work as before, and Cyrus ignores its own messages, edits, and plain channel chatter outside threads it's part of. **Existing Slack apps must add the `message.channels`, `message.groups`, `message.mpim`, and `message.im` bot events in their Slack app's Event Subscriptions for this to take effect.** ([CYPACK-1267](https://linear.app/ceedar/issue/CYPACK-1267), [#1273](https://github.com/cyrusagents/cyrus/pull/1273)) +- Cyrus now follows along in Slack threads it's been pulled into: once you @mention it in a thread, every later reply in that thread is fed to the same session automatically — no need to re-mention it each time. To avoid chiming in on every message, Cyrus only replies when a message is a question it can genuinely help with or is addressed to it by name; it stays quiet on side conversation, acknowledgements, and chatter not aimed at it. Replies that do @mention it still work as before, and Cyrus ignores its own messages, edits, and plain channel chatter outside threads it's part of. **Existing Slack apps must add the `message.channels`, `message.groups`, `message.mpim`, and `message.im` bot events in their Slack app's Event Subscriptions for this to take effect.** ([CYPACK-1267](https://linear.app/ceedar/issue/CYPACK-1267), [#1273](https://github.com/cyrusagents/cyrus/pull/1273)) - A repository can now ship its own skills: any skill directories under `/.claude/skills/` are automatically discovered and made available to the agent whenever Cyrus works in that repo — for single-repo issues, multi-repo issues (skills from every participating repo are combined), and GitHub/GitLab mentions alike. ([CYPACK-1261](https://linear.app/ceedar/issue/CYPACK-1261), [#1268](https://github.com/cyrusagents/cyrus/pull/1268)) ## [0.2.60] - 2026-05-28 diff --git a/packages/edge-worker/src/SlackChatAdapter.ts b/packages/edge-worker/src/SlackChatAdapter.ts index fd10b0a26..66fa3d8d5 100644 --- a/packages/edge-worker/src/SlackChatAdapter.ts +++ b/packages/edge-worker/src/SlackChatAdapter.ts @@ -10,6 +10,18 @@ import { import type { ChatRepositoryProvider } from "./ChatRepositoryProvider.js"; import type { ChatPlatformAdapter } from "./ChatSessionHandler.js"; +/** + * Sentinel the agent emits when it has decided a Slack message does not warrant + * a reply. `postReply` recognizes it and stays silent instead of posting. + * + * This is what makes the "only respond when relevant" policy in the system + * prompt actually take effect: because every completed turn would otherwise be + * posted back to the thread, the agent needs an explicit way to say "nothing to + * post here". Kept as a single constant so the prompt and the suppression check + * can never drift apart. + */ +export const SLACK_NO_RESPONSE_SENTINEL = "<>"; + /** * Slack implementation of ChatPlatformAdapter. * @@ -114,12 +126,21 @@ ${repositoryPaths.map((path) => `- ${path}`).join("\n")} ## Repository Access - No repository paths are configured for this chat session.`; - return `You are responding to a Slack @mention. + return `You are participating in a Slack thread. ## Context - **Requested by**: ${event.payload.user} - **Channel**: ${event.payload.channel} +## When to Respond (IMPORTANT) +- After you are first @mentioned, you receive **every** subsequent message in this thread, not just the ones aimed at you. Do not treat every message as a request for you. +- Respond ONLY when at least one of these is true: + 1. The message asks a question you can genuinely and helpfully answer, OR + 2. Someone addresses you directly — by name ("Cyrus, …") or with an @mention. +- For anything else — side conversation between people, acknowledgements ("thanks", "👍"), status chatter, or messages clearly not directed at you — do NOT reply. +- When you should stay silent, output exactly \`${SLACK_NO_RESPONSE_SENTINEL}\` and nothing else. Do not explain why you're staying silent — just emit the token. (Emitting it is how you stay quiet; any other text gets posted to the thread.) +- When you do respond, be genuinely helpful and concise. + ## Instructions - You are running in a transient workspace, not associated with any code repository - Be concise in your responses as they will be posted back to Slack @@ -235,6 +256,16 @@ Supported mrkdwn syntax: } } + // The agent emits the no-response sentinel when it judged this message + // didn't warrant a reply (see the "When to Respond" system prompt + // section). Honor that by posting nothing. + if (summary.trim() === SLACK_NO_RESPONSE_SENTINEL) { + this.logger.info( + `Slack agent opted not to respond in channel ${event.payload.channel} (no-response sentinel)`, + ); + return; + } + const token = this.getSlackBotToken(event); if (!token) { this.logger.warn("Cannot post Slack reply: no slackBotToken available"); diff --git a/packages/edge-worker/test/chat-sessions.test.ts b/packages/edge-worker/test/chat-sessions.test.ts index ba91ac5ed..d9fcd50ff 100644 --- a/packages/edge-worker/test/chat-sessions.test.ts +++ b/packages/edge-worker/test/chat-sessions.test.ts @@ -1,13 +1,17 @@ import { join } from "node:path"; import { getReadOnlyTools } from "cyrus-claude-runner"; import type { RepositoryConfig } from "cyrus-core"; -import { describe, expect, it, vi } from "vitest"; +import { SlackMessageService } from "cyrus-slack-event-transport"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChatRepositoryProvider } from "../src/ChatRepositoryProvider.js"; import { LiveChatRepositoryProvider } from "../src/ChatRepositoryProvider.js"; import type { ChatPlatformAdapter } from "../src/ChatSessionHandler.js"; import { ChatSessionHandler } from "../src/ChatSessionHandler.js"; import type { RunnerConfigBuilder } from "../src/RunnerConfigBuilder.js"; -import { SlackChatAdapter } from "../src/SlackChatAdapter.js"; +import { + SLACK_NO_RESPONSE_SENTINEL, + SlackChatAdapter, +} from "../src/SlackChatAdapter.js"; import { TEST_CYRUS_CHAT } from "./test-dirs.js"; function createMockRunnerConfigBuilder(): RunnerConfigBuilder { @@ -223,6 +227,78 @@ describe("SlackChatAdapter session initiation", () => { }); }); +describe("SlackChatAdapter responding policy", () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.SLACK_BOT_TOKEN; + }); + + const slackEvent = (text: string) => + ({ + eventType: "message", + eventId: "Ev1", + teamId: "T1", + slackBotToken: "xoxb-test", + payload: { + type: "message", + user: "U1", + channel: "C1", + text, + ts: "1700000000.000200", + thread_ts: "1700000000.000100", + event_ts: "1700000000.000200", + }, + }) as any; + + const runnerWithReply = (text: string) => + ({ + getMessages: () => [ + { type: "assistant", message: { content: [{ type: "text", text }] } }, + ], + }) as any; + + it("documents the when-to-respond policy and the silence sentinel in the system prompt", () => { + const adapter = new SlackChatAdapter(createStaticProvider([])); + const prompt = adapter.buildSystemPrompt(slackEvent("anything")); + expect(prompt).toContain("## When to Respond"); + expect(prompt).toContain("Someone addresses you directly"); + expect(prompt).toContain(SLACK_NO_RESPONSE_SENTINEL); + }); + + it("does NOT post to Slack when the agent emits the no-response sentinel", async () => { + const adapter = new SlackChatAdapter(createStaticProvider([])); + const postSpy = vi + .spyOn(SlackMessageService.prototype, "postMessage") + .mockResolvedValue({} as any); + + await adapter.postReply( + slackEvent("thanks team!"), + runnerWithReply(` ${SLACK_NO_RESPONSE_SENTINEL}\n`), + ); + + expect(postSpy).not.toHaveBeenCalled(); + }); + + it("posts to Slack when the agent produces a real reply", async () => { + const adapter = new SlackChatAdapter(createStaticProvider([])); + const postSpy = vi + .spyOn(SlackMessageService.prototype, "postMessage") + .mockResolvedValue({} as any); + + await adapter.postReply( + slackEvent("Cyrus, what does this function do?"), + runnerWithReply("It memoizes the result."), + ); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy.mock.calls[0]?.[0]).toMatchObject({ + channel: "C1", + text: "It memoizes the result.", + thread_ts: "1700000000.000100", + }); + }); +}); + describe("SlackChatAdapter system prompt", () => { it("includes configured repository context and git pull instructions", () => { const repositoryPaths = ["/repo/chat-one", "/repo/chat-two"]; From ce87eb9f6da13706aecbf85fbb54645ee47aed71 Mon Sep 17 00:00:00 2001 From: agentclear Date: Mon, 1 Jun 2026 10:06:29 -0700 Subject: [PATCH 4/4] fix(slack): keep answering thread follow-ups after a restart In proxy mode CYHOST only forwards 'message' events for threads that have a persistent cyrus_assignments binding row, so a forwarded message reaching the runtime means the thread IS bound. But the runtime's thread->session map is in-memory and wiped on restart, so post-restart plain replies were dropped with 'Ignoring non-initiating slack event for unbound thread' until the user re-@mentioned. Thread the verification mode through SlackEventTransport as an 'upstreamGated' flag on the emitted event (proxy=true, direct=false). SlackChatAdapter now treats an upstream-gated message as session-initiating, so it (re)starts a session for the bound thread and rehydrates context via fetchThreadContext. Direct mode (no upstream gate) still self-gates on in-memory bindings to avoid starting sessions for arbitrary channel chatter. CYPACK-1267 --- CHANGELOG.md | 2 +- packages/edge-worker/src/SlackChatAdapter.ts | 18 +++++++++++----- .../edge-worker/test/chat-sessions.test.ts | 21 +++++++++++++++++-- .../src/SlackEventTransport.ts | 12 +++++++++-- packages/slack-event-transport/src/types.ts | 10 +++++++++ .../test/SlackEventTransport.test.ts | 4 ++++ 6 files changed, 57 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 028ff1e91..6ed690028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file. - Updated `@anthropic-ai/claude-agent-sdk` from 0.3.154 to 0.3.156 and `@anthropic-ai/sdk` from 0.100.0 to 0.100.1. See the [SDK changelog](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md) for details. ([CYPACK-1265](https://linear.app/ceedar/issue/CYPACK-1265), [#1270](https://github.com/cyrusagents/cyrus/pull/1270)) ### Added -- Cyrus now follows along in Slack threads it's been pulled into: once you @mention it in a thread, every later reply in that thread is fed to the same session automatically — no need to re-mention it each time. To avoid chiming in on every message, Cyrus only replies when a message is a question it can genuinely help with or is addressed to it by name; it stays quiet on side conversation, acknowledgements, and chatter not aimed at it. Replies that do @mention it still work as before, and Cyrus ignores its own messages, edits, and plain channel chatter outside threads it's part of. **Existing Slack apps must add the `message.channels`, `message.groups`, `message.mpim`, and `message.im` bot events in their Slack app's Event Subscriptions for this to take effect.** ([CYPACK-1267](https://linear.app/ceedar/issue/CYPACK-1267), [#1273](https://github.com/cyrusagents/cyrus/pull/1273)) +- Cyrus now follows along in Slack threads it's been pulled into: once you @mention it in a thread, every later reply in that thread is fed to the same session automatically — no need to re-mention it each time. To avoid chiming in on every message, Cyrus only replies when a message is a question it can genuinely help with or is addressed to it by name; it stays quiet on side conversation, acknowledgements, and chatter not aimed at it. Replies that do @mention it still work as before, and Cyrus ignores its own messages, edits, and plain channel chatter outside threads it's part of. Thread follow-ups keep working even after Cyrus restarts — it no longer goes silent on plain replies until you re-@mention it. **Existing Slack apps must add the `message.channels`, `message.groups`, `message.mpim`, and `message.im` bot events in their Slack app's Event Subscriptions for this to take effect.** ([CYPACK-1267](https://linear.app/ceedar/issue/CYPACK-1267), [#1273](https://github.com/cyrusagents/cyrus/pull/1273)) - A repository can now ship its own skills: any skill directories under `/.claude/skills/` are automatically discovered and made available to the agent whenever Cyrus works in that repo — for single-repo issues, multi-repo issues (skills from every participating repo are combined), and GitHub/GitLab mentions alike. ([CYPACK-1261](https://linear.app/ceedar/issue/CYPACK-1261), [#1268](https://github.com/cyrusagents/cyrus/pull/1268)) ## [0.2.60] - 2026-05-28 diff --git a/packages/edge-worker/src/SlackChatAdapter.ts b/packages/edge-worker/src/SlackChatAdapter.ts index 66fa3d8d5..12d010372 100644 --- a/packages/edge-worker/src/SlackChatAdapter.ts +++ b/packages/edge-worker/src/SlackChatAdapter.ts @@ -87,13 +87,21 @@ export class SlackChatAdapter } /** - * Only an explicit @mention may start a new session. Plain `message` events - * are follow-ups: they continue a thread Cyrus is already bound to, but - * never spin up a session on their own (otherwise every message in a watched - * channel would start one). + * Decide whether an event may start a session when the runtime has no + * in-memory binding for its thread. + * + * - An explicit @mention always may. + * - A plain `message` event may only when it was upstream-gated (proxy mode): + * CYHOST forwards `message` events solely for threads it has a persistent + * binding row for, so reaching us means the thread is genuinely bound. This + * is what lets Cyrus keep answering follow-ups after a process restart wipes + * the in-memory binding — the prior Slack thread is rehydrated via + * `fetchThreadContext`. In direct mode (`upstreamGated` false) there is no + * such guarantee, so an unbound plain message is ignored to avoid starting a + * session for arbitrary channel chatter. */ isSessionInitiatingEvent(event: SlackWebhookEvent): boolean { - return event.eventType === "app_mention"; + return event.eventType === "app_mention" || event.upstreamGated === true; } getThreadKey(event: SlackWebhookEvent): string { diff --git a/packages/edge-worker/test/chat-sessions.test.ts b/packages/edge-worker/test/chat-sessions.test.ts index d9fcd50ff..bc9c771c7 100644 --- a/packages/edge-worker/test/chat-sessions.test.ts +++ b/packages/edge-worker/test/chat-sessions.test.ts @@ -216,15 +216,32 @@ describe("ChatSessionHandler session-initiation gate", () => { }); describe("SlackChatAdapter session initiation", () => { - it("treats app_mention as session-initiating and message as a follow-up", () => { + it("treats app_mention as session-initiating", () => { const adapter = new SlackChatAdapter(createStaticProvider([])); expect( adapter.isSessionInitiatingEvent({ eventType: "app_mention" } as any), ).toBe(true); + }); + + it("ignores a non-upstream-gated message (direct mode, unbound thread)", () => { + const adapter = new SlackChatAdapter(createStaticProvider([])); expect( - adapter.isSessionInitiatingEvent({ eventType: "message" } as any), + adapter.isSessionInitiatingEvent({ + eventType: "message", + upstreamGated: false, + } as any), ).toBe(false); }); + + it("treats an upstream-gated message as session-initiating (proxy mode survives restart)", () => { + const adapter = new SlackChatAdapter(createStaticProvider([])); + expect( + adapter.isSessionInitiatingEvent({ + eventType: "message", + upstreamGated: true, + } as any), + ).toBe(true); + }); }); describe("SlackChatAdapter responding policy", () => { diff --git a/packages/slack-event-transport/src/SlackEventTransport.ts b/packages/slack-event-transport/src/SlackEventTransport.ts index 1b19a125d..fa78c3949 100644 --- a/packages/slack-event-transport/src/SlackEventTransport.ts +++ b/packages/slack-event-transport/src/SlackEventTransport.ts @@ -195,7 +195,10 @@ export class SlackEventTransport extends EventEmitter { return; } - this.processAndEmitEvent(request, reply); + // Direct mode: Slack delivers events straight to us with no upstream + // gate, so the runtime must self-gate plain messages on its in-memory + // thread bindings. + this.processAndEmitEvent(request, reply, false); } catch (error) { const err = new Error("Slack signature verification failed"); if (error instanceof Error) { @@ -252,7 +255,10 @@ export class SlackEventTransport extends EventEmitter { } try { - this.processAndEmitEvent(request, reply); + // Proxy mode: CYHOST already verified this event against its + // persistent thread bindings before forwarding, so a `message` event + // reaching us is trusted to (re)start a session for its thread. + this.processAndEmitEvent(request, reply, true); } catch (error) { const err = new Error("Proxy webhook processing failed"); if (error instanceof Error) { @@ -269,6 +275,7 @@ export class SlackEventTransport extends EventEmitter { private processAndEmitEvent( request: FastifyRequest, reply: FastifyReply, + upstreamGated: boolean, ): void { const envelope = request.body as SlackEventEnvelope; @@ -332,6 +339,7 @@ export class SlackEventTransport extends EventEmitter { payload: event, slackBotToken, teamId: envelope.team_id, + upstreamGated, }; this.logger.info( diff --git a/packages/slack-event-transport/src/types.ts b/packages/slack-event-transport/src/types.ts index 0a8560d7c..d854585ff 100644 --- a/packages/slack-event-transport/src/types.ts +++ b/packages/slack-event-transport/src/types.ts @@ -50,6 +50,16 @@ export interface SlackWebhookEvent { slackBotToken?: string; /** Workspace/team ID */ teamId: string; + /** + * True when the event arrived via an upstream gate that already verified it + * should be acted on (proxy mode: CYHOST only forwards `message` events for + * threads it has a persistent binding row for). When true, a plain `message` + * event is trusted to (re)start a session for its thread even if the runtime + * has no in-memory binding — e.g. after a process restart. In direct mode + * (Slack → runtime, no upstream gate) this is false and the runtime must + * self-gate on its in-memory thread bindings. + */ + upstreamGated?: boolean; } /** diff --git a/packages/slack-event-transport/test/SlackEventTransport.test.ts b/packages/slack-event-transport/test/SlackEventTransport.test.ts index 9a3a2fa24..bcb9a6d55 100644 --- a/packages/slack-event-transport/test/SlackEventTransport.test.ts +++ b/packages/slack-event-transport/test/SlackEventTransport.test.ts @@ -132,6 +132,8 @@ describe("SlackEventTransport", () => { eventType: "app_mention", eventId: "Ev0001", teamId: "T0001", + // Proxy mode: CYHOST already gated this event upstream. + upstreamGated: true, }), ); }); @@ -571,6 +573,8 @@ describe("SlackEventTransport", () => { eventType: "app_mention", eventId: "Ev0001", teamId: "T0001", + // Direct mode: no upstream gate, runtime must self-gate. + upstreamGated: false, }), ); });