diff --git a/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts index e660612b5ce..5f2d9993531 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts @@ -2,8 +2,8 @@ import type { GenericMessageEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; -import { runSlackAgent } from "../utils/run-agent"; -import { formatActionsAsText } from "../utils/slack-blocks"; +import { formatErrorForSlack, runSlackAgent } from "../utils/run-agent"; +import { formatSideEffectsMessage } from "../utils/slack-blocks"; import { createSlackClient } from "../utils/slack-client"; interface ProcessAssistantMessageParams { @@ -43,15 +43,18 @@ export async function processAssistantMessage({ const threadTs = event.thread_ts ?? event.ts; + // Post an initial message that gets updated as the agent works + let messageTs: string | undefined; try { - await slack.assistant.threads.setStatus({ - channel_id: event.channel, + const initialMsg = await slack.chat.postMessage({ + channel: event.channel, thread_ts: threadTs, - status: "Thinking...", + text: "Thinking...", }); + messageTs = initialMsg.ts; } catch (err) { - console.warn( - "[slack/process-assistant-message] Failed to set status:", + console.error( + "[slack/process-assistant-message] Failed to post initial message:", err, ); } @@ -63,34 +66,67 @@ export async function processAssistantMessage({ threadTs, organizationId: connection.organizationId, slackToken: connection.accessToken, + onProgress: messageTs + ? async (status) => { + try { + await slack.chat.update({ + channel: event.channel, + ts: messageTs, + text: status, + }); + } catch { + // Non-critical: progress updates are best-effort + } + } + : undefined, }); - // Format actions as text with URLs (enables Slack unfurling) - const hasActions = result.actions.length > 0; - const responseText = hasActions - ? formatActionsAsText(result.actions) - : result.text; + // Update the message with Claude's final summary + if (messageTs) { + await slack.chat.update({ + channel: event.channel, + ts: messageTs, + text: result.text, + }); + } else { + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: result.text, + }); + } - await slack.chat.postMessage({ - channel: event.channel, - thread_ts: threadTs, - text: responseText, - }); + // Post side effects as a separate message + if (result.actions.length > 0) { + try { + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: formatSideEffectsMessage(result.actions), + }); + } catch (err) { + console.error( + "[slack/process-assistant-message] Failed to post side effects:", + err, + ); + } + } } catch (err) { console.error("[slack/process-assistant-message] Agent error:", err); - await slack.chat.postMessage({ - channel: event.channel, - thread_ts: threadTs, - text: `Sorry, something went wrong: ${err instanceof Error ? err.message : "Unknown error"}`, - }); - } finally { - try { - await slack.assistant.threads.setStatus({ - channel_id: event.channel, + const errorText = await formatErrorForSlack(err); + if (messageTs) { + await slack.chat.update({ + channel: event.channel, + ts: messageTs, + text: errorText, + }); + } else { + await slack.chat.postMessage({ + channel: event.channel, thread_ts: threadTs, - status: "", + text: errorText, }); - } catch {} + } } } diff --git a/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts b/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts index 01e95be47c9..860f0a0f7be 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts @@ -2,8 +2,8 @@ import type { AppMentionEvent } from "@slack/types"; import { db } from "@superset/db/client"; import { integrationConnections } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; -import { runSlackAgent } from "../utils/run-agent"; -import { formatActionsAsText } from "../utils/slack-blocks"; +import { formatErrorForSlack, runSlackAgent } from "../utils/run-agent"; +import { formatSideEffectsMessage } from "../utils/slack-blocks"; import { createSlackClient } from "../utils/slack-client"; interface ProcessMentionParams { @@ -53,6 +53,22 @@ export async function processSlackMention({ const threadTs = event.thread_ts ?? event.ts; + // Post an initial message that gets updated as the agent works + let messageTs: string | undefined; + try { + const initialMsg = await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: "Thinking...", + }); + messageTs = initialMsg.ts; + } catch (err) { + console.error( + "[slack/process-mention] Failed to post initial message:", + err, + ); + } + try { const result = await runSlackAgent({ prompt: event.text, @@ -60,27 +76,68 @@ export async function processSlackMention({ threadTs, organizationId: connection.organizationId, slackToken: connection.accessToken, + onProgress: messageTs + ? async (status) => { + try { + await slack.chat.update({ + channel: event.channel, + ts: messageTs, + text: status, + }); + } catch { + // Non-critical: progress updates are best-effort + } + } + : undefined, }); - // Format actions as text with URLs (enables Slack unfurling) - const hasActions = result.actions.length > 0; - const responseText = hasActions - ? formatActionsAsText(result.actions) - : result.text; + // Update the message with Claude's final summary + if (messageTs) { + await slack.chat.update({ + channel: event.channel, + ts: messageTs, + text: result.text, + }); + } else { + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: result.text, + }); + } - await slack.chat.postMessage({ - channel: event.channel, - thread_ts: threadTs, - text: responseText, - }); + // Post side effects as a separate message + if (result.actions.length > 0) { + try { + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: formatSideEffectsMessage(result.actions), + }); + } catch (err) { + console.error( + "[slack/process-mention] Failed to post side effects:", + err, + ); + } + } } catch (err) { console.error("[slack/process-mention] Agent error:", err); - await slack.chat.postMessage({ - channel: event.channel, - thread_ts: threadTs, - text: `Sorry, something went wrong: ${err instanceof Error ? err.message : "Unknown error"}`, - }); + const errorText = await formatErrorForSlack(err); + if (messageTs) { + await slack.chat.update({ + channel: event.channel, + ts: messageTs, + text: errorText, + }); + } else { + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: threadTs, + text: errorText, + }); + } } finally { try { await slack.reactions.remove({ diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts index 111971d8f96..50bffc25ad5 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/index.ts @@ -1,2 +1,2 @@ export type { SlackAgentResult } from "./run-agent"; -export { runSlackAgent } from "./run-agent"; +export { formatErrorForSlack, runSlackAgent } from "./run-agent"; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts index c0ad3d552b3..b556f89a286 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts @@ -58,6 +58,7 @@ interface RunSlackAgentParams { threadTs: string; organizationId: string; slackToken: string; + onProgress?: (status: string) => void | Promise; } export interface SlackAgentResult { @@ -65,6 +66,34 @@ export interface SlackAgentResult { actions: AgentAction[]; } +export async function formatErrorForSlack(error: unknown): Promise { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + try { + const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); + const response = await anthropic.messages.create({ + model: "claude-3-5-haiku-latest", + max_tokens: 256, + messages: [ + { + role: "user", + content: `Rewrite this API error as a brief, friendly Slack message (1-2 sentences). No technical jargon, no JSON. If it's a rate limit, tell them to try again shortly.\n\nError: ${message}`, + }, + ], + }); + const text = response.content.find( + (b): b is Anthropic.TextBlock => b.type === "text", + ); + return text?.text ?? "Sorry, something went wrong. Please try again."; + } catch { + // Haiku itself failed (possibly also rate limited) — use static fallback + if (error instanceof Anthropic.APIError && error.status === 429) { + return "I'm a bit overloaded right now — please try again in a moment."; + } + return "Sorry, something went wrong. Please try again."; + } +} + function getActionFromToolResult( toolName: string, // biome-ignore lint/suspicious/noExplicitAny: MCP result varies by tool @@ -100,6 +129,19 @@ function getActionFromToolResult( }; } + if (toolName === "delete_task" && data.deleted) { + return { + type: "task_deleted", + tasks: ( + data.deleted as { id: string; slug: string; title: string }[] + ).map((t) => ({ + id: t.id, + slug: t.slug, + title: t.title, + })), + }; + } + if (toolName === "create_workspace" && data.workspaceId) { return { type: "workspace_created", @@ -149,6 +191,21 @@ function parseTextContent(content: any): Record | null { } } +const TOOL_PROGRESS_STATUS: Record = { + create_task: "Creating task...", + update_task: "Updating task...", + delete_task: "Deleting task...", + list_tasks: "Searching tasks...", + get_task: "Fetching task details...", + list_task_statuses: "Fetching statuses...", + create_workspace: "Creating workspace...", + list_workspaces: "Fetching workspaces...", + list_devices: "Fetching devices...", + list_projects: "Fetching projects...", + list_members: "Fetching team members...", + slack_get_channel_history: "Reading channel history...", +}; + // Desktop-only tools that don't make sense in Slack context const DENIED_SUPERSET_TOOLS = new Set([ "navigate_to_workspace", @@ -271,7 +328,7 @@ export async function runSlackAgent( { type: "web_search_20250305" as const, name: "web_search" as const, - max_uses: 3, + max_uses: 1, }, ]; @@ -313,6 +370,11 @@ Current context: // pause_turn: server-side tool (web search) paused a long-running turn if (response.stop_reason === "pause_turn") { + try { + await params.onProgress?.("Searching the web..."); + } catch { + // Non-critical + } messages.push({ role: "assistant", content: response.content }); response = await anthropic.messages.create({ model: "claude-sonnet-4-5", @@ -333,6 +395,18 @@ Current context: for (const toolUse of toolUseBlocks) { try { + const { toolName: rawToolName } = parseToolName(toolUse.name); + const progressStatus = + TOOL_PROGRESS_STATUS[toolUse.name] ?? + TOOL_PROGRESS_STATUS[rawToolName] ?? + "Working..."; + + try { + await params.onProgress?.(progressStatus); + } catch { + // Non-critical: don't fail the agent if progress update fails + } + let resultContent: string; if (toolUse.name === "slack_get_channel_history") { @@ -407,9 +481,12 @@ Current context: }); } - const textBlock = response.content.find( + // Use the last text block — server-side tools like web_search produce + // multiple text blocks (preamble + synthesis) and we want the final one. + const textBlocks = response.content.filter( (b): b is Anthropic.TextBlock => b.type === "text", ); + const textBlock = textBlocks.at(-1); return { text: textBlock?.text ?? "Done!", diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts index b6476f41d92..91d05994840 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/index.ts @@ -1,2 +1,2 @@ export type { AgentAction } from "./slack-blocks"; -export { formatActionsAsText } from "./slack-blocks"; +export { formatActionsAsText, formatSideEffectsMessage } from "./slack-blocks"; diff --git a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts index 5af0931c8d9..356f2114f96 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/slack-blocks/slack-blocks.ts @@ -58,3 +58,37 @@ export function formatActionsAsText(actions: AgentAction[]): string { return lines.join("\n"); } + +export function formatSideEffectsMessage(actions: AgentAction[]): string { + const lines: string[] = []; + + for (const action of actions) { + if (action.type === "task_created") { + for (const task of action.tasks) { + const url = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; + lines.push(`• Created task <${url}|${task.slug}>`); + } + } else if (action.type === "task_updated") { + for (const task of action.tasks) { + const url = `${env.NEXT_PUBLIC_WEB_URL}/tasks/${task.slug}`; + lines.push(`• Updated task <${url}|${task.slug}>`); + } + } else if (action.type === "task_deleted") { + for (const task of action.tasks) { + lines.push(`• Deleted task ${task.slug}`); + } + } else if (action.type === "workspace_created") { + for (const ws of action.workspaces) { + lines.push( + `• Created workspace *${ws.name}*${ws.branch ? ` on branch \`${ws.branch}\`` : ""}`, + ); + } + } else if (action.type === "workspace_switched") { + for (const ws of action.workspaces) { + lines.push(`• Switched to workspace *${ws.name}*`); + } + } + } + + return `*Changes:*\n${lines.join("\n")}`; +}