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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added
- Slack sessions now post a message with the Linear issue link when Cyrus creates an issue via the Linear MCP tool ([CYPACK-1048](https://linear.app/ceedar/issue/CYPACK-1048), [#1075](https://github.com/ceedaragents/cyrus/pull/1075))

## [0.2.41] - 2026-04-06

### Changed
Expand Down
54 changes: 54 additions & 0 deletions packages/edge-worker/src/AgentSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ import type {
IActivitySink,
} from "./sinks/index.js";

/**
* Callback fired when a tool result is processed by the session manager.
* Allows consumers (e.g. ChatSessionHandler) to react to specific tool completions.
*/
export type ToolResultCallback = (info: {
sessionId: string;
toolName: string;
toolInput: any;
toolResultContent: string;
isError: boolean;
}) => void;

/**
* Events emitted by AgentSessionManager
*/
Expand Down Expand Up @@ -71,6 +83,7 @@ export class AgentSessionManager extends EventEmitter {
private taskSubjectsById: Map<string, string> = new Map(); // Cache task subjects by task ID (e.g., "1" → "Fix login bug")
private activeStatusActivitiesBySession: Map<string, string> = new Map(); // Maps session ID to active compacting status activity ID
private stopRequestedSessions: Set<string> = new Set(); // Sessions explicitly stopped by user signal
private toolResultCallbacks: ToolResultCallback[] = [];
private getParentSessionId?: (childSessionId: string) => string | undefined;
private resumeParentSession?: (
parentSessionId: string,
Expand Down Expand Up @@ -101,6 +114,13 @@ export class AgentSessionManager extends EventEmitter {
this.activitySinks.set(sessionId, sink);
}

/**
* Register a callback that fires whenever a tool result is processed.
*/
onToolResult(callback: ToolResultCallback): void {
this.toolResultCallbacks.push(callback);
}

/**
* Get the activity sink for a session.
*/
Expand Down Expand Up @@ -708,6 +728,32 @@ export class AgentSessionManager extends EventEmitter {
};
}

/**
* Fire registered tool result callbacks (fire-and-forget, errors are logged).
*/
private fireToolResultCallbacks(
sessionId: string,
toolName: string,
toolInput: any,
toolResult: { content: string; isError: boolean },
): void {
for (const cb of this.toolResultCallbacks) {
try {
cb({
sessionId,
toolName,
toolInput,
toolResultContent: toolResult.content,
isError: toolResult.isError,
});
} catch (err) {
this.logger.warn(
`Tool result callback error: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}

/**
* Sync session entry to external tracker (create AgentActivity)
*/
Expand Down Expand Up @@ -751,6 +797,14 @@ export class AgentSessionManager extends EventEmitter {
const toolName = originalTool?.name || "Tool";
const toolInput = originalTool?.input || "";

// Fire tool result callbacks (async, fire-and-forget)
this.fireToolResultCallbacks(
sessionId,
toolName,
toolInput,
toolResult,
);

// Clean up the tool call from our tracking map
if (entry.metadata.toolUseId) {
this.toolCallsByToolUseId.delete(entry.metadata.toolUseId);
Expand Down
112 changes: 112 additions & 0 deletions packages/edge-worker/src/ChatSessionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export interface ChatPlatformAdapter<TEvent> {

/** Notify the user that a previous request is still processing */
notifyBusy(event: TEvent, threadKey: string): Promise<void>;

/** Post a mid-session notification message to the thread (e.g. issue creation alerts) */
postThreadMessage?(event: TEvent, message: string): Promise<void>;
}

/**
Expand Down Expand Up @@ -81,6 +84,7 @@ export class ChatSessionHandler<TEvent> {
private adapter: ChatPlatformAdapter<TEvent>;
private sessionManager: AgentSessionManager;
private threadSessions: Map<string, string> = new Map();
private sessionEvents: Map<string, TEvent> = new Map();
private deps: ChatSessionHandlerDeps;
private logger: ILogger;

Expand All @@ -98,6 +102,15 @@ export class ChatSessionHandler<TEvent> {
undefined, // No parent session lookup
undefined, // No resume parent session
);

// Listen for tool results to post mid-session notifications (e.g. issue creation links)
this.sessionManager.onToolResult((info) => {
this.handleToolResultNotification(info).catch((err) => {
this.logger.warn(
`Failed to handle tool result notification: ${err instanceof Error ? err.message : String(err)}`,
);
});
});
}

/**
Expand Down Expand Up @@ -132,6 +145,8 @@ export class ChatSessionHandler<TEvent> {
this.sessionManager.getAgentRunner(existingSessionId);

if (existingSession && existingRunner?.isRunning()) {
// Keep event reference up to date for mid-session notifications
this.sessionEvents.set(existingSessionId, event);
// Session is actively running — inject the follow-up via streaming input
if (
existingRunner.addStreamMessage &&
Expand Down Expand Up @@ -217,6 +232,7 @@ export class ChatSessionHandler<TEvent> {

// Track this thread → session mapping for follow-up messages
this.threadSessions.set(threadKey, sessionId);
this.sessionEvents.set(sessionId, event);

// Initialize session metadata
if (!session.metadata) {
Expand Down Expand Up @@ -285,6 +301,102 @@ export class ChatSessionHandler<TEvent> {
}
}

/**
* Handle tool result notifications — post mid-session messages to the thread
* when the agent creates a Linear issue via MCP.
*/
private async handleToolResultNotification(info: {
sessionId: string;
toolName: string;
toolInput: any;
toolResultContent: string;
isError: boolean;
}): Promise<void> {
// Only handle successful Linear issue creation
if (info.toolName !== "mcp__linear__save_issue" || info.isError) {
return;
}

// Only post if the adapter supports mid-session thread messages
if (!this.adapter.postThreadMessage) {
return;
}

const event = this.sessionEvents.get(info.sessionId);
if (!event) {
return;
}

// Extract issue URL from the tool result
const issueUrl = this.extractLinearIssueUrl(info.toolResultContent);
if (!issueUrl) {
this.logger.warn(
`Could not extract Linear issue URL from save_issue result for session ${info.sessionId}`,
);
return;
}

const issueIdentifier = this.extractLinearIssueIdentifier(
info.toolResultContent,
);
const displayText = issueIdentifier ? `${issueIdentifier}` : "Linear issue";

try {
await this.adapter.postThreadMessage(
event,
`Created ${displayText}: ${issueUrl}`,
);
this.logger.info(
`Posted issue creation notification for session ${info.sessionId}: ${issueUrl}`,
);
} catch (err) {
this.logger.warn(
`Failed to post issue creation notification: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

/**
* Extract a Linear issue URL from MCP tool result content.
* Handles both JSON responses and plain text containing URLs.
*/
private extractLinearIssueUrl(content: string): string | null {
// Try JSON parse first
try {
const parsed = JSON.parse(content);
if (parsed.url && typeof parsed.url === "string") {
return parsed.url;
}
} catch {
// Not JSON — fall through to regex
}

// Match Linear issue URLs in text
const urlMatch = content.match(
/https:\/\/linear\.app\/[^/\s]+\/issue\/[A-Z]+-\d+[^\s)"]*/,
);
return urlMatch?.[0] ?? null;
}

/**
* Extract a Linear issue identifier (e.g. "CYPACK-1234") from MCP tool result content.
*/
private extractLinearIssueIdentifier(content: string): string | null {
// Try JSON parse first
try {
const parsed = JSON.parse(content);
if (parsed.identifier && typeof parsed.identifier === "string") {
return parsed.identifier;
}
} catch {
// Not JSON — fall through to regex
}

// Match identifier patterns in text
const identifierMatch = content.match(/\b[A-Z]+-\d+\b/);
return identifierMatch?.[0] ?? null;
}

/** Returns true if any runner managed by this handler is currently busy */
isAnyRunnerBusy(): boolean {
for (const runner of this.sessionManager.getAllAgentRunners()) {
Expand Down
22 changes: 22 additions & 0 deletions packages/edge-worker/src/SlackChatAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,28 @@ Supported mrkdwn syntax:
});
}

async postThreadMessage(
event: SlackWebhookEvent,
message: string,
): Promise<void> {
const token = this.getSlackBotToken(event);
if (!token) {
this.logger.warn(
"Cannot post Slack thread message: no slackBotToken available",
);
return;
}

const threadTs = event.payload.thread_ts || event.payload.ts;

await new SlackMessageService().postMessage({
token,
channel: event.payload.channel,
text: message,
thread_ts: threadTs,
});
}

async notifyBusy(event: SlackWebhookEvent): Promise<void> {
const token = this.getSlackBotToken(event);
if (!token) {
Expand Down
1 change: 1 addition & 0 deletions packages/edge-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
UserIdentifier,
Workspace,
} from "cyrus-core";
export type { ToolResultCallback } from "./AgentSessionManager.js";
export { AgentSessionManager } from "./AgentSessionManager.js";
export type {
AskUserQuestionHandlerConfig,
Expand Down
Loading
Loading