From 01395eeb9c282d183fbefd5b67a3882d6ef7e151 Mon Sep 17 00:00:00 2001 From: agentclear Date: Mon, 6 Apr 2026 16:28:25 -0700 Subject: [PATCH 1/2] feat: post Linear issue link to Slack thread on MCP issue creation (CYPACK-1048) When Cyrus creates a Linear issue via `mcp__linear__save_issue` during a Slack session, a notification message with the issue link is now posted to the Slack thread automatically. - Add `ToolResultCallback` mechanism to `AgentSessionManager` for reacting to tool results - Add `postThreadMessage` to `ChatPlatformAdapter` interface for mid-session thread notifications - Implement the hook in `ChatSessionHandler` to detect Linear issue creation and post the link - Add `postThreadMessage` to `SlackChatAdapter` --- CHANGELOG.md | 3 + .../edge-worker/src/AgentSessionManager.ts | 54 ++++ .../edge-worker/src/ChatSessionHandler.ts | 112 +++++++ packages/edge-worker/src/SlackChatAdapter.ts | 22 ++ packages/edge-worker/src/index.ts | 1 + ...essionManager.tool-result-callback.test.ts | 255 +++++++++++++++ ...nHandler.linear-issue-notification.test.ts | 297 ++++++++++++++++++ 7 files changed, 744 insertions(+) create mode 100644 packages/edge-worker/test/AgentSessionManager.tool-result-callback.test.ts create mode 100644 packages/edge-worker/test/ChatSessionHandler.linear-issue-notification.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e35aad5b..61d68d2eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) + ## [0.2.41] - 2026-04-06 ### Changed diff --git a/packages/edge-worker/src/AgentSessionManager.ts b/packages/edge-worker/src/AgentSessionManager.ts index a2414cf23..72ad4c7f0 100644 --- a/packages/edge-worker/src/AgentSessionManager.ts +++ b/packages/edge-worker/src/AgentSessionManager.ts @@ -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 */ @@ -71,6 +83,7 @@ export class AgentSessionManager extends EventEmitter { private taskSubjectsById: Map = new Map(); // Cache task subjects by task ID (e.g., "1" → "Fix login bug") private activeStatusActivitiesBySession: Map = new Map(); // Maps session ID to active compacting status activity ID private stopRequestedSessions: Set = new Set(); // Sessions explicitly stopped by user signal + private toolResultCallbacks: ToolResultCallback[] = []; private getParentSessionId?: (childSessionId: string) => string | undefined; private resumeParentSession?: ( parentSessionId: string, @@ -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. */ @@ -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) */ @@ -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); diff --git a/packages/edge-worker/src/ChatSessionHandler.ts b/packages/edge-worker/src/ChatSessionHandler.ts index 977819829..1fd90e3bd 100644 --- a/packages/edge-worker/src/ChatSessionHandler.ts +++ b/packages/edge-worker/src/ChatSessionHandler.ts @@ -48,6 +48,9 @@ export interface ChatPlatformAdapter { /** Notify the user that a previous request is still processing */ notifyBusy(event: TEvent, threadKey: string): Promise; + + /** Post a mid-session notification message to the thread (e.g. issue creation alerts) */ + postThreadMessage?(event: TEvent, message: string): Promise; } /** @@ -81,6 +84,7 @@ export class ChatSessionHandler { private adapter: ChatPlatformAdapter; private sessionManager: AgentSessionManager; private threadSessions: Map = new Map(); + private sessionEvents: Map = new Map(); private deps: ChatSessionHandlerDeps; private logger: ILogger; @@ -98,6 +102,15 @@ export class ChatSessionHandler { 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)}`, + ); + }); + }); } /** @@ -132,6 +145,8 @@ export class ChatSessionHandler { 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 && @@ -217,6 +232,7 @@ export class ChatSessionHandler { // 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) { @@ -285,6 +301,102 @@ export class ChatSessionHandler { } } + /** + * 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 { + // 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()) { diff --git a/packages/edge-worker/src/SlackChatAdapter.ts b/packages/edge-worker/src/SlackChatAdapter.ts index 8e28f1793..c7bbbc943 100644 --- a/packages/edge-worker/src/SlackChatAdapter.ts +++ b/packages/edge-worker/src/SlackChatAdapter.ts @@ -266,6 +266,28 @@ Supported mrkdwn syntax: }); } + async postThreadMessage( + event: SlackWebhookEvent, + message: string, + ): Promise { + 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 { const token = this.getSlackBotToken(event); if (!token) { diff --git a/packages/edge-worker/src/index.ts b/packages/edge-worker/src/index.ts index a3e16d850..06547abed 100644 --- a/packages/edge-worker/src/index.ts +++ b/packages/edge-worker/src/index.ts @@ -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, diff --git a/packages/edge-worker/test/AgentSessionManager.tool-result-callback.test.ts b/packages/edge-worker/test/AgentSessionManager.tool-result-callback.test.ts new file mode 100644 index 000000000..9c9952ed9 --- /dev/null +++ b/packages/edge-worker/test/AgentSessionManager.tool-result-callback.test.ts @@ -0,0 +1,255 @@ +import { ClaudeMessageFormatter } from "cyrus-claude-runner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentSessionManager } from "../src/AgentSessionManager"; +import type { IActivitySink } from "../src/sinks/IActivitySink"; + +describe("AgentSessionManager - Tool Result Callbacks", () => { + let manager: AgentSessionManager; + let mockActivitySink: IActivitySink; + const sessionId = "test-session"; + + beforeEach(() => { + mockActivitySink = { + id: "test-workspace", + postActivity: vi.fn().mockResolvedValue({ activityId: "activity-123" }), + createAgentSession: vi.fn().mockResolvedValue("session-123"), + }; + + manager = new AgentSessionManager(); + + manager.createCyrusAgentSession( + sessionId, + "issue-1", + { + id: "issue-1", + identifier: "TEST-100", + title: "Test issue", + description: "", + branchName: "test-branch", + }, + { path: "/tmp/test", isGitWorktree: false }, + ); + manager.setActivitySink(sessionId, mockActivitySink); + + // Add a mock runner with formatter + const mockRunner = { + isRunning: () => true, + getMessages: () => [], + getFormatter: () => new ClaudeMessageFormatter(), + getUsage: () => ({ inputTokens: 0, outputTokens: 0, totalCost: 0 }), + }; + manager.addAgentRunner(sessionId, mockRunner as any); + }); + + it("fires registered callbacks when a tool result is processed", async () => { + const callback = vi.fn(); + manager.onToolResult(callback); + + // Send assistant message with tool_use + await manager.handleClaudeMessage(sessionId, { + type: "assistant", + message: { + id: "msg-1", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "mcp__linear__save_issue", + input: { title: "New issue", teamId: "team-1" }, + }, + ], + model: "claude-sonnet-4-20250514", + stop_reason: "tool_use", + usage: { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } as any); + + // Send user message with tool_result + await manager.handleClaudeMessage(sessionId, { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: JSON.stringify({ + id: "abc123", + identifier: "TEST-200", + url: "https://linear.app/test/issue/TEST-200/new-issue", + }), + }, + ], + }, + } as any); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + sessionId, + toolName: "mcp__linear__save_issue", + toolInput: { title: "New issue", teamId: "team-1" }, + toolResultContent: expect.stringContaining("TEST-200"), + isError: false, + }); + }); + + it("fires multiple registered callbacks", async () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + manager.onToolResult(callback1); + manager.onToolResult(callback2); + + // Send tool_use + tool_result + await manager.handleClaudeMessage(sessionId, { + type: "assistant", + message: { + id: "msg-1", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-2", + name: "Bash", + input: { command: "ls" }, + }, + ], + model: "claude-sonnet-4-20250514", + stop_reason: "tool_use", + usage: { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } as any); + + await manager.handleClaudeMessage(sessionId, { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-2", + content: "file1.ts\nfile2.ts", + }, + ], + }, + } as any); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("does not fire callback for error tool results when is_error is true", async () => { + const callback = vi.fn(); + manager.onToolResult(callback); + + await manager.handleClaudeMessage(sessionId, { + type: "assistant", + message: { + id: "msg-1", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-3", + name: "mcp__linear__save_issue", + input: { title: "New issue" }, + }, + ], + model: "claude-sonnet-4-20250514", + stop_reason: "tool_use", + usage: { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } as any); + + await manager.handleClaudeMessage(sessionId, { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-3", + is_error: true, + content: "Error: failed to create issue", + }, + ], + }, + } as any); + + // Callback still fires, but with isError: true + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ isError: true }), + ); + }); + + it("continues processing when a callback throws", async () => { + const badCallback = vi.fn().mockImplementation(() => { + throw new Error("callback error"); + }); + const goodCallback = vi.fn(); + manager.onToolResult(badCallback); + manager.onToolResult(goodCallback); + + await manager.handleClaudeMessage(sessionId, { + type: "assistant", + message: { + id: "msg-1", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-4", + name: "Bash", + input: { command: "echo hi" }, + }, + ], + model: "claude-sonnet-4-20250514", + stop_reason: "tool_use", + usage: { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } as any); + + await manager.handleClaudeMessage(sessionId, { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-4", + content: "hi", + }, + ], + }, + } as any); + + // Both callbacks were called even though the first threw + expect(badCallback).toHaveBeenCalledTimes(1); + expect(goodCallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/edge-worker/test/ChatSessionHandler.linear-issue-notification.test.ts b/packages/edge-worker/test/ChatSessionHandler.linear-issue-notification.test.ts new file mode 100644 index 000000000..101bc598f --- /dev/null +++ b/packages/edge-worker/test/ChatSessionHandler.linear-issue-notification.test.ts @@ -0,0 +1,297 @@ +import type { IAgentRunner } from "cyrus-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + ChatPlatformAdapter, + ChatSessionHandlerDeps, +} from "../src/ChatSessionHandler"; +import { ChatSessionHandler } from "../src/ChatSessionHandler"; +import type { RunnerConfigBuilder } from "../src/RunnerConfigBuilder"; + +/** + * Minimal mock event for testing chat session handler. + */ +interface MockEvent { + id: string; + channel: string; + thread_ts: string; + text: string; +} + +/** + * Creates a mock ChatPlatformAdapter for testing. + */ +function createMockAdapter( + overrides?: Partial>, +): ChatPlatformAdapter { + return { + platformName: "slack", + extractTaskInstructions: (event) => event.text, + getThreadKey: (event) => `${event.channel}:${event.thread_ts}`, + getEventId: (event) => event.id, + buildSystemPrompt: () => "You are a helpful assistant.", + fetchThreadContext: vi.fn().mockResolvedValue(""), + postReply: vi.fn().mockResolvedValue(undefined), + acknowledgeReceipt: vi.fn().mockResolvedValue(undefined), + notifyBusy: vi.fn().mockResolvedValue(undefined), + postThreadMessage: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +/** + * Creates a mock runner that resolves immediately with messages. + */ +function createMockRunner(): IAgentRunner { + const messages: any[] = []; + return { + isRunning: () => false, + supportsStreamingInput: false, + getMessages: () => messages, + start: vi.fn().mockResolvedValue({ sessionId: "test-session" }), + stop: vi.fn().mockResolvedValue(undefined), + getFormatter: () => null, + getUsage: () => ({ inputTokens: 0, outputTokens: 0, totalCost: 0 }), + } as unknown as IAgentRunner; +} + +/** + * Creates mock ChatSessionHandlerDeps. + */ +function createMockDeps(runner: IAgentRunner): ChatSessionHandlerDeps { + const mockRunnerConfigBuilder = { + buildChatConfig: vi.fn().mockReturnValue({ + workingDirectory: "/tmp/test", + systemPrompt: "test", + }), + } as unknown as RunnerConfigBuilder; + + return { + cyrusHome: "/tmp/cyrus-test", + runnerConfigBuilder: mockRunnerConfigBuilder, + createRunner: () => runner, + onWebhookStart: vi.fn(), + onWebhookEnd: vi.fn(), + onStateChange: vi.fn().mockResolvedValue(undefined), + onClaudeError: vi.fn(), + }; +} + +describe("ChatSessionHandler - Linear Issue Creation Notification", () => { + describe("extractLinearIssueUrl", () => { + let handler: ChatSessionHandler; + + beforeEach(() => { + const adapter = createMockAdapter(); + const runner = createMockRunner(); + const deps = createMockDeps(runner); + handler = new ChatSessionHandler(adapter, deps); + }); + + it("extracts URL from JSON response", () => { + const content = JSON.stringify({ + id: "abc123", + identifier: "CYPACK-1234", + title: "Test issue", + url: "https://linear.app/ceedar/issue/CYPACK-1234/test-issue", + }); + + const result = (handler as any).extractLinearIssueUrl(content); + expect(result).toBe( + "https://linear.app/ceedar/issue/CYPACK-1234/test-issue", + ); + }); + + it("extracts URL from plain text", () => { + const content = + "Created issue CYPACK-1234: https://linear.app/ceedar/issue/CYPACK-1234/test-issue"; + + const result = (handler as any).extractLinearIssueUrl(content); + expect(result).toBe( + "https://linear.app/ceedar/issue/CYPACK-1234/test-issue", + ); + }); + + it("returns null when no URL found", () => { + const content = "Issue created successfully"; + const result = (handler as any).extractLinearIssueUrl(content); + expect(result).toBeNull(); + }); + + it("handles empty content", () => { + const result = (handler as any).extractLinearIssueUrl(""); + expect(result).toBeNull(); + }); + }); + + describe("extractLinearIssueIdentifier", () => { + let handler: ChatSessionHandler; + + beforeEach(() => { + const adapter = createMockAdapter(); + const runner = createMockRunner(); + const deps = createMockDeps(runner); + handler = new ChatSessionHandler(adapter, deps); + }); + + it("extracts identifier from JSON response", () => { + const content = JSON.stringify({ + identifier: "CYPACK-1234", + title: "Test issue", + }); + + const result = (handler as any).extractLinearIssueIdentifier(content); + expect(result).toBe("CYPACK-1234"); + }); + + it("extracts identifier from plain text", () => { + const content = "Created issue CYPACK-1234 successfully"; + const result = (handler as any).extractLinearIssueIdentifier(content); + expect(result).toBe("CYPACK-1234"); + }); + + it("returns null when no identifier found", () => { + const content = "Issue created successfully"; + const result = (handler as any).extractLinearIssueIdentifier(content); + expect(result).toBeNull(); + }); + }); + + describe("handleToolResultNotification", () => { + let adapter: ChatPlatformAdapter; + let handler: ChatSessionHandler; + const mockEvent: MockEvent = { + id: "evt-1", + channel: "C123", + thread_ts: "1704110400.000100", + text: "Create an issue", + }; + + beforeEach(() => { + adapter = createMockAdapter(); + const runner = createMockRunner(); + const deps = createMockDeps(runner); + handler = new ChatSessionHandler(adapter, deps); + + // Simulate storing a session event + (handler as any).sessionEvents.set("test-session", mockEvent); + }); + + it("posts thread message when Linear issue is created", async () => { + const toolResultContent = JSON.stringify({ + id: "abc123", + identifier: "CYPACK-1234", + title: "Test issue", + url: "https://linear.app/ceedar/issue/CYPACK-1234/test-issue", + }); + + await (handler as any).handleToolResultNotification({ + sessionId: "test-session", + toolName: "mcp__linear__save_issue", + toolInput: { title: "Test issue" }, + toolResultContent, + isError: false, + }); + + expect(adapter.postThreadMessage).toHaveBeenCalledWith( + mockEvent, + "Created CYPACK-1234: https://linear.app/ceedar/issue/CYPACK-1234/test-issue", + ); + }); + + it("does not post when tool is not mcp__linear__save_issue", async () => { + await (handler as any).handleToolResultNotification({ + sessionId: "test-session", + toolName: "mcp__linear__get_issue", + toolInput: { id: "CYPACK-1234" }, + toolResultContent: "some result", + isError: false, + }); + + expect(adapter.postThreadMessage).not.toHaveBeenCalled(); + }); + + it("does not post when tool result is an error", async () => { + await (handler as any).handleToolResultNotification({ + sessionId: "test-session", + toolName: "mcp__linear__save_issue", + toolInput: { title: "Test issue" }, + toolResultContent: "Error creating issue", + isError: true, + }); + + expect(adapter.postThreadMessage).not.toHaveBeenCalled(); + }); + + it("does not post when no event is stored for session", async () => { + await (handler as any).handleToolResultNotification({ + sessionId: "unknown-session", + toolName: "mcp__linear__save_issue", + toolInput: { title: "Test issue" }, + toolResultContent: JSON.stringify({ + url: "https://linear.app/ceedar/issue/CYPACK-1234/test", + }), + isError: false, + }); + + expect(adapter.postThreadMessage).not.toHaveBeenCalled(); + }); + + it("does not post when adapter lacks postThreadMessage", async () => { + const adapterWithoutPost = createMockAdapter(); + delete (adapterWithoutPost as any).postThreadMessage; + const runner = createMockRunner(); + const deps = createMockDeps(runner); + const handlerWithoutPost = new ChatSessionHandler( + adapterWithoutPost, + deps, + ); + (handlerWithoutPost as any).sessionEvents.set("test-session", mockEvent); + + await (handlerWithoutPost as any).handleToolResultNotification({ + sessionId: "test-session", + toolName: "mcp__linear__save_issue", + toolInput: { title: "Test issue" }, + toolResultContent: JSON.stringify({ + url: "https://linear.app/ceedar/issue/CYPACK-1234/test", + }), + isError: false, + }); + + // No error thrown, just silently skipped + }); + + it("does not post when URL cannot be extracted from result", async () => { + await (handler as any).handleToolResultNotification({ + sessionId: "test-session", + toolName: "mcp__linear__save_issue", + toolInput: { title: "Test issue" }, + toolResultContent: "Issue created but no URL in response", + isError: false, + }); + + expect(adapter.postThreadMessage).not.toHaveBeenCalled(); + }); + + it("uses plain display text when identifier cannot be extracted", async () => { + const toolResultContent = JSON.stringify({ + url: "https://linear.app/ceedar/issue/CYPACK-1234/test-issue", + }); + + await (handler as any).handleToolResultNotification({ + sessionId: "test-session", + toolName: "mcp__linear__save_issue", + toolInput: { title: "Test issue" }, + toolResultContent, + isError: false, + }); + + // URL contains CYPACK-1234, which the regex will still pick up from the URL itself + expect(adapter.postThreadMessage).toHaveBeenCalledWith( + mockEvent, + expect.stringContaining( + "https://linear.app/ceedar/issue/CYPACK-1234/test-issue", + ), + ); + }); + }); +}); From 1579c232c88f9523ca654953478c1d97f53df1d4 Mon Sep 17 00:00:00 2001 From: agentclear Date: Mon, 6 Apr 2026 16:29:19 -0700 Subject: [PATCH 2/2] chore: add PR link to changelog entry (CYPACK-1048) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d68d2eb..3d8279281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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)) +- 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