Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), [#1273](https://github.com/cyrusagents/cyrus/pull/1273))
- 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))

## [0.2.60] - 2026-05-28
Expand Down
25 changes: 25 additions & 0 deletions packages/edge-worker/src/ChatSessionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ export interface ChatPlatformAdapter<TEvent> {
/** 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;

Expand Down Expand Up @@ -199,6 +210,20 @@ export class ChatSessionHandler<TEvent> {
);
}

// 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) {
Expand Down
10 changes: 10 additions & 0 deletions packages/edge-worker/src/SlackChatAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
73 changes: 73 additions & 0 deletions packages/edge-worker/test/chat-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,79 @@ describe("ChatSessionHandler chat session permissions", () => {
});
});

describe("ChatSessionHandler session-initiation gate", () => {
function buildHandler(adapter: ChatPlatformAdapter<TestEvent>) {
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<TestEvent> = 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<TestEvent> = 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"];
Expand Down
99 changes: 94 additions & 5 deletions packages/slack-event-transport/src/SlackEventTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
SlackEventEnvelope,
SlackEventTransportConfig,
SlackEventTransportEvents,
SlackMessageEvent,
SlackVerificationMode,
SlackWebhookEvent,
} from "./types.js";
Expand All @@ -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<string, number> = new Map();
private static readonly DEDUP_TTL_MS = 10 * 60 * 1000;

constructor(
config: SlackEventTransportConfig,
Expand Down Expand Up @@ -271,29 +287,55 @@ 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,
teamId: envelope.team_id,
};

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
Expand All @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions packages/slack-event-transport/src/SlackMessageTranslator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}`,
Expand All @@ -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);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/slack-event-transport/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ export type {
SlackAppMentionEvent,
SlackChannel,
SlackEventEnvelope,
SlackEventPayload,
SlackEventTransportConfig,
SlackEventTransportEvents,
SlackEventType,
SlackMessageEvent,
SlackUser,
SlackVerificationMode,
SlackWebhookEvent,
Expand Down
Loading
Loading