Skip to content

Commit 8a83a6a

Browse files
committed
feat(slack): follow all replies in threads Cyrus is bound to
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
1 parent 73b06a0 commit 8a83a6a

12 files changed

Lines changed: 477 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
88
- 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))
99

1010
### Added
11+
- 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))
1112
- A repository can now ship its own skills: any skill directories under `<repo>/.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))
1213

1314
## [0.2.60] - 2026-05-28

packages/edge-worker/src/ChatSessionHandler.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ export interface ChatPlatformAdapter<TEvent> {
2828
/** Extract the user's task text from the raw event */
2929
extractTaskInstructions(event: TEvent): string;
3030

31+
/**
32+
* Whether this event is allowed to *start* a brand-new session for its
33+
* thread. Events that may only continue an already-bound thread (e.g. a
34+
* plain Slack message that isn't an @mention) return false, so the handler
35+
* ignores them when no session exists yet.
36+
*
37+
* Optional — when omitted, every event is treated as session-initiating
38+
* (the behaviour for platforms where every event is an explicit invocation).
39+
*/
40+
isSessionInitiatingEvent?(event: TEvent): boolean;
41+
3142
/** Derive a unique thread key for session tracking (e.g., "C123:1704110400.000100") */
3243
getThreadKey(event: TEvent): string;
3344

@@ -199,6 +210,20 @@ export class ChatSessionHandler<TEvent> {
199210
);
200211
}
201212

213+
// No session exists for this thread. Only events explicitly allowed to
214+
// start a session may do so — e.g. a Slack @mention. A plain follow-up
215+
// message in an unbound thread must be ignored, otherwise every message
216+
// in any channel Cyrus can see would spin up a session.
217+
if (
218+
!existingSessionId &&
219+
this.adapter.isSessionInitiatingEvent?.(event) === false
220+
) {
221+
this.logger.info(
222+
`Ignoring non-initiating ${this.adapter.platformName} event for unbound thread ${threadKey}`,
223+
);
224+
return;
225+
}
226+
202227
// Create an empty workspace directory for this thread
203228
const workspace = await this.createWorkspace(threadKey);
204229
if (!workspace) {

packages/edge-worker/src/SlackChatAdapter.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ export class SlackChatAdapter
7474
);
7575
}
7676

77+
/**
78+
* Only an explicit @mention may start a new session. Plain `message` events
79+
* are follow-ups: they continue a thread Cyrus is already bound to, but
80+
* never spin up a session on their own (otherwise every message in a watched
81+
* channel would start one).
82+
*/
83+
isSessionInitiatingEvent(event: SlackWebhookEvent): boolean {
84+
return event.eventType === "app_mention";
85+
}
86+
7787
getThreadKey(event: SlackWebhookEvent): string {
7888
const threadTs = event.payload.thread_ts || event.payload.ts;
7989
return `${event.payload.channel}:${threadTs}`;

packages/edge-worker/test/chat-sessions.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,79 @@ describe("ChatSessionHandler chat session permissions", () => {
150150
});
151151
});
152152

153+
describe("ChatSessionHandler session-initiation gate", () => {
154+
function buildHandler(adapter: ChatPlatformAdapter<TestEvent>) {
155+
const createRunner = vi.fn(
156+
() =>
157+
({
158+
supportsStreamingInput: false,
159+
start: vi.fn().mockResolvedValue({ sessionId: "session-1" }),
160+
stop: vi.fn(),
161+
isRunning: vi.fn().mockReturnValue(false),
162+
isStreaming: vi.fn().mockReturnValue(false),
163+
addStreamMessage: vi.fn(),
164+
getMessages: vi.fn().mockReturnValue([]),
165+
}) as any,
166+
);
167+
const handler = new ChatSessionHandler(adapter, {
168+
cyrusHome: TEST_CYRUS_CHAT,
169+
chatRepositoryProvider: createStaticProvider([]),
170+
runnerConfigBuilder: createMockRunnerConfigBuilder(),
171+
createRunner,
172+
onWebhookStart: vi.fn(),
173+
onWebhookEnd: vi.fn(),
174+
onStateChange: vi.fn().mockResolvedValue(undefined),
175+
onClaudeError: vi.fn(),
176+
});
177+
return { handler, createRunner };
178+
}
179+
180+
it("ignores a non-initiating event when no session exists for the thread", async () => {
181+
const adapter: ChatPlatformAdapter<TestEvent> = new TestChatAdapter(
182+
"unbound-thread",
183+
);
184+
// Mark this event as a follow-up that must not start a session.
185+
adapter.isSessionInitiatingEvent = () => false;
186+
187+
const { handler, createRunner } = buildHandler(adapter);
188+
await handler.handleEvent({
189+
eventId: "follow-up",
190+
threadKey: "unbound-thread",
191+
} as any);
192+
193+
expect(createRunner).not.toHaveBeenCalled();
194+
expect(handler.listThreads()).toHaveLength(0);
195+
});
196+
197+
it("starts a session for an initiating event", async () => {
198+
const adapter: ChatPlatformAdapter<TestEvent> = new TestChatAdapter(
199+
"bound-thread",
200+
);
201+
adapter.isSessionInitiatingEvent = () => true;
202+
203+
const { handler, createRunner } = buildHandler(adapter);
204+
await handler.handleEvent({
205+
eventId: "mention",
206+
threadKey: "bound-thread",
207+
} as any);
208+
209+
expect(createRunner).toHaveBeenCalledTimes(1);
210+
expect(handler.listThreads()).toHaveLength(1);
211+
});
212+
});
213+
214+
describe("SlackChatAdapter session initiation", () => {
215+
it("treats app_mention as session-initiating and message as a follow-up", () => {
216+
const adapter = new SlackChatAdapter(createStaticProvider([]));
217+
expect(
218+
adapter.isSessionInitiatingEvent({ eventType: "app_mention" } as any),
219+
).toBe(true);
220+
expect(
221+
adapter.isSessionInitiatingEvent({ eventType: "message" } as any),
222+
).toBe(false);
223+
});
224+
});
225+
153226
describe("SlackChatAdapter system prompt", () => {
154227
it("includes configured repository context and git pull instructions", () => {
155228
const repositoryPaths = ["/repo/chat-one", "/repo/chat-two"];

packages/slack-event-transport/src/SlackEventTransport.ts

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
SlackEventEnvelope,
99
SlackEventTransportConfig,
1010
SlackEventTransportEvents,
11+
SlackMessageEvent,
1112
SlackVerificationMode,
1213
SlackWebhookEvent,
1314
} from "./types.js";
@@ -33,13 +34,28 @@ export declare interface SlackEventTransport {
3334
* and verifies incoming webhooks using Bearer token authentication.
3435
*
3536
* Supported Slack event types:
36-
* - app_mention: When the bot is mentioned with @ in a channel or thread
37+
* - app_mention: When the bot is mentioned with @ in a channel or thread.
38+
* Always emitted — starts or resumes a session for the thread.
39+
* - message: A plain message in a channel/thread the bot can see. Emitted only
40+
* for threaded replies that aren't the bot's own message and aren't a
41+
* subtype event (edits, joins, etc.). Whether it actually does anything is
42+
* decided downstream (ChatSessionHandler only continues already-bound
43+
* threads). Slack delivers both an `app_mention` AND a `message` event for a
44+
* message that mentions the bot, so identical `(channel, ts)` pairs are
45+
* de-duplicated here to avoid double-prompting.
3746
*/
3847
export class SlackEventTransport extends EventEmitter {
3948
private config: SlackEventTransportConfig;
4049
private logger: ILogger;
4150
private messageTranslator: SlackMessageTranslator;
4251
private translationContext: TranslationContext;
52+
/**
53+
* Recently emitted `channel:ts` keys, used to collapse Slack's
54+
* double-delivery of app_mention + message for the same underlying message.
55+
* Maps key → epoch ms first seen; pruned by TTL.
56+
*/
57+
private recentMessageKeys: Map<string, number> = new Map();
58+
private static readonly DEDUP_TTL_MS = 10 * 60 * 1000;
4359

4460
constructor(
4561
config: SlackEventTransportConfig,
@@ -271,29 +287,55 @@ export class SlackEventTransport extends EventEmitter {
271287

272288
const event = envelope.event;
273289

274-
if (!event || event.type !== "app_mention") {
290+
// Slack sends many event types at runtime; the envelope type only models
291+
// the two we handle, so widen to string for the membership check.
292+
const eventType = event?.type as string | undefined;
293+
if (eventType !== "app_mention" && eventType !== "message") {
275294
this.logger.debug(
276-
`Ignoring unsupported event type: ${event?.type ?? "unknown"}`,
295+
`Ignoring unsupported event type: ${eventType ?? "unknown"}`,
277296
);
278297
reply.code(200).send({ success: true, ignored: true });
279298
return;
280299
}
281300

301+
// `message` events fire for every message in every channel the bot can
302+
// see, so apply cheap structural filters before doing any work. Anything
303+
// that gets through here is a candidate follow-up prompt; the binding
304+
// check (is this thread actually bound to Cyrus?) happens downstream.
305+
if (event.type === "message" && !this.shouldEmitMessageEvent(event)) {
306+
reply.code(200).send({ success: true, ignored: true });
307+
return;
308+
}
309+
310+
// Slack delivers both an app_mention and a message event for a single
311+
// message that mentions the bot. De-duplicate on (channel, ts) so the
312+
// thread only gets prompted once. The first event to arrive wins; both
313+
// carry identical text.
314+
const dedupKey = `${event.channel}:${event.ts}`;
315+
if (this.isDuplicateMessage(dedupKey)) {
316+
this.logger.debug(
317+
`Ignoring duplicate Slack event for ${dedupKey} (already processed)`,
318+
);
319+
reply.code(200).send({ success: true, ignored: true });
320+
return;
321+
}
322+
this.rememberMessage(dedupKey);
323+
282324
// Token may be undefined during startup transitions (e.g. switching runtimes)
283325
// when the env update hasn't been processed yet. Downstream consumers
284326
// (SlackChatAdapter) fall back to process.env.SLACK_BOT_TOKEN at usage time.
285327
const slackBotToken = this.getSlackBotToken();
286328

287329
const webhookEvent: SlackWebhookEvent = {
288-
eventType: "app_mention",
330+
eventType: event.type,
289331
eventId: envelope.event_id,
290332
payload: event,
291333
slackBotToken,
292334
teamId: envelope.team_id,
293335
};
294336

295337
this.logger.info(
296-
`Received app_mention webhook (event: ${envelope.event_id}, channel: ${event.channel})`,
338+
`Received ${event.type} webhook (event: ${envelope.event_id}, channel: ${event.channel})`,
297339
);
298340

299341
// Emit "event" for transport-level listeners
@@ -305,6 +347,53 @@ export class SlackEventTransport extends EventEmitter {
305347
reply.code(200).send({ success: true });
306348
}
307349

350+
/**
351+
* Decide whether a `message` event is a candidate follow-up prompt.
352+
*
353+
* Drops the bot's own messages (which would otherwise loop), edited/deleted
354+
* and other subtype events, and top-level (non-threaded) messages — only a
355+
* threaded reply can belong to a thread Cyrus is already bound to.
356+
*/
357+
private shouldEmitMessageEvent(event: SlackMessageEvent): boolean {
358+
if (event.bot_id) {
359+
this.logger.debug(
360+
`Ignoring Slack message from bot ${event.bot_id} (channel ${event.channel})`,
361+
);
362+
return false;
363+
}
364+
if (event.subtype) {
365+
this.logger.debug(
366+
`Ignoring Slack message with subtype "${event.subtype}" (channel ${event.channel})`,
367+
);
368+
return false;
369+
}
370+
if (!event.thread_ts) {
371+
this.logger.debug(
372+
`Ignoring non-threaded Slack message (channel ${event.channel})`,
373+
);
374+
return false;
375+
}
376+
return true;
377+
}
378+
379+
private isDuplicateMessage(key: string): boolean {
380+
this.pruneRecentMessageKeys();
381+
return this.recentMessageKeys.has(key);
382+
}
383+
384+
private rememberMessage(key: string): void {
385+
this.recentMessageKeys.set(key, Date.now());
386+
}
387+
388+
private pruneRecentMessageKeys(): void {
389+
const now = Date.now();
390+
for (const [key, seenAt] of this.recentMessageKeys) {
391+
if (now - seenAt > SlackEventTransport.DEDUP_TTL_MS) {
392+
this.recentMessageKeys.delete(key);
393+
}
394+
}
395+
}
396+
308397
/**
309398
* Translate and emit an internal message from a webhook event.
310399
* Only emits if translation succeeds; logs debug message on failure.

packages/slack-event-transport/src/SlackMessageTranslator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export class SlackMessageTranslator
5353

5454
return (
5555
typeof e.eventType === "string" &&
56-
e.eventType === "app_mention" &&
56+
(e.eventType === "app_mention" || e.eventType === "message") &&
5757
typeof e.eventId === "string" &&
5858
e.payload !== null &&
5959
typeof e.payload === "object"
@@ -75,6 +75,13 @@ export class SlackMessageTranslator
7575
return this.translateAppMention(event, context);
7676
}
7777

78+
// A plain `message` event is always a follow-up in an existing thread —
79+
// it can only reach here for a thread Cyrus is already bound to, so it
80+
// maps to a user prompt rather than a session start.
81+
if (event.eventType === "message") {
82+
return this.translateAppMentionAsUserPrompt(event, context);
83+
}
84+
7885
return {
7986
success: false,
8087
reason: `Unsupported Slack event type: ${event.eventType}`,
@@ -90,7 +97,7 @@ export class SlackMessageTranslator
9097
event: SlackWebhookEvent,
9198
context?: TranslationContext,
9299
): TranslationResult {
93-
if (event.eventType === "app_mention") {
100+
if (event.eventType === "app_mention" || event.eventType === "message") {
94101
return this.translateAppMentionAsUserPrompt(event, context);
95102
}
96103

packages/slack-event-transport/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ export type {
1515
SlackAppMentionEvent,
1616
SlackChannel,
1717
SlackEventEnvelope,
18+
SlackEventPayload,
1819
SlackEventTransportConfig,
1920
SlackEventTransportEvents,
2021
SlackEventType,
22+
SlackMessageEvent,
2123
SlackUser,
2224
SlackVerificationMode,
2325
SlackWebhookEvent,

0 commit comments

Comments
 (0)