diff --git a/src/app/discord/webhook/route.ts b/src/app/discord/webhook/route.ts index 7b80b678f..dc122d0bf 100644 --- a/src/app/discord/webhook/route.ts +++ b/src/app/discord/webhook/route.ts @@ -9,6 +9,8 @@ import { postDiscordMessage, addDiscordReaction, removeDiscordReaction, + getInstallationByGuildId, + getOwnerFromInstallation, } from '@/lib/integrations/discord-service'; import { stripDiscordBotMention, @@ -17,15 +19,89 @@ import { truncateForDiscord, } from '@/lib/discord-bot/discord-utils'; import { getDevUserSuffix } from '@/lib/slack-bot/dev-user-info'; +import { APP_URL } from '@/lib/constants'; +import { db } from '@/lib/drizzle'; +import { cli_sessions_v2 } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; + +import type { Owner } from '@/lib/integrations/core/types'; export const maxDuration = 800; +/** + * Internal timeout (ms) — must be lower than Vercel's maxDuration so we can + * send a graceful message before the function is killed. + */ +const ENDPOINT_TIMEOUT_MS = 750 * 1000; + /** * Reaction emoji for processing state */ const PROCESSING_EMOJI = '\u23f3'; // hourglass const COMPLETE_EMOJI = '\u2705'; // white check mark +/** + * Build the session URL for a cloud agent session based on the owner type + */ +function buildSessionUrl(dbSessionId: string, owner: Owner): string { + const basePath = owner.type === 'org' ? `/organizations/${owner.id}/cloud` : '/cloud'; + return `${APP_URL}${basePath}/chat?sessionId=${dbSessionId}`; +} + +/** + * Look up the database session UUID from the cloud agent session ID + */ +async function getDbSessionIdFromCloudAgentId(cloudAgentSessionId: string): Promise { + const [session] = await db + .select({ session_id: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .where(eq(cli_sessions_v2.cloud_agent_session_id, cloudAgentSessionId)) + .limit(1); + + return session?.session_id ?? null; +} + +/** + * Post a message with a link to the Cloud Agent session + */ +async function postSessionLinkMessage({ + channelId, + messageId, + cloudAgentSessionId, + guildId, +}: { + channelId: string; + messageId: string; + cloudAgentSessionId: string; + guildId: string; +}): Promise { + const installation = await getInstallationByGuildId(guildId); + if (!installation) { + console.error('[DiscordBot:Webhook] Could not find installation for session link'); + return; + } + + const owner = getOwnerFromInstallation(installation); + if (!owner) { + console.error('[DiscordBot:Webhook] Could not determine owner for session link'); + return; + } + + const dbSessionId = await getDbSessionIdFromCloudAgentId(cloudAgentSessionId); + if (!dbSessionId) { + console.error( + '[DiscordBot:Webhook] Could not find database session for cloud agent session:', + cloudAgentSessionId + ); + return; + } + + const sessionUrl = buildSessionUrl(dbSessionId, owner); + await postDiscordMessage(channelId, `Cloud Agent session started — follow along here: ${sessionUrl}`, { + messageReference: { message_id: messageId }, + }); +} + /** * Forwarded Gateway event shape (from the Gateway listener) */ @@ -155,14 +231,63 @@ async function processGatewayMessage(event: ForwardedGatewayEvent) { const startTime = Date.now(); - // Process through bot - const result = await processDiscordBotMessage(resolvedText, guildId, { + // Post a session link as soon as the Cloud Agent session is created + let sessionLinkSent = false; + const onCloudAgentSessionCreated = (cloudAgentSessionId: string) => { + if (sessionLinkSent) return; + sessionLinkSent = true; + postSessionLinkMessage({ + channelId, + messageId, + cloudAgentSessionId, + guildId, + }).catch(err => { + console.error('[DiscordBot:Webhook] Failed to send session link:', err); + }); + }; + + // Process through bot, with an internal timeout that fires before Vercel's + // maxDuration so we can send a graceful message to the user. + const botPromise = processDiscordBotMessage(resolvedText, guildId, { channelId, guildId, userId: author.id, messageId, + onCloudAgentSessionCreated, }); + const timeoutPromise = new Promise(resolve => { + setTimeout(() => resolve(null), ENDPOINT_TIMEOUT_MS); + }); + + const result = await Promise.race([botPromise, timeoutPromise]); + + if (result === null) { + // Internal timeout fired before the bot finished + console.log('[DiscordBot:Webhook] Internal endpoint timeout reached'); + + const timeoutMessage = truncateForDiscord( + 'The Cloud Agent session is taking longer than expected. ' + + 'Our endpoint is shutting down, but the session is still running. ' + + 'Check the session link above to follow along.' + ); + await postDiscordMessage(channelId, timeoutMessage, { + messageReference: { message_id: messageId }, + }); + + // Swap reactions to indicate we're done (from our side) + const [removeResult] = await Promise.all([ + removeDiscordReaction(channelId, messageId, PROCESSING_EMOJI), + addDiscordReaction(channelId, messageId, COMPLETE_EMOJI), + ]); + if (!removeResult.ok) { + await removeDiscordReaction(channelId, messageId, PROCESSING_EMOJI); + } + + console.log('[DiscordBot:Webhook] processGatewayMessage timed out gracefully'); + return; + } + const responseTimeMs = Date.now() - startTime; console.log(`[DiscordBot:Webhook] Bot processing completed in ${responseTimeMs}ms`); diff --git a/src/app/slack/webhook/route.ts b/src/app/slack/webhook/route.ts index 311d1ce73..20f847629 100644 --- a/src/app/slack/webhook/route.ts +++ b/src/app/slack/webhook/route.ts @@ -31,6 +31,12 @@ import type { PlatformIntegration } from '@kilocode/db/schema'; export const maxDuration = 800; +/** + * Internal timeout (ms) — must be lower than Vercel's maxDuration so we can + * send a graceful message before the function is killed. + */ +const ENDPOINT_TIMEOUT_MS = 750 * 1000; + /** * Reaction emoji names */ @@ -228,13 +234,81 @@ async function processSlackMessage(event: AppMentionEvent | GenericMessageEvent, name: PROCESSING_REACTION, }); - // Process the message through Kilo Bot - const result = await processKiloBotMessage(kiloInputText, teamId, { + // Send an ephemeral session link as soon as the Cloud Agent session is created + let sessionLinkSent = false; + const onCloudAgentSessionCreated = (cloudAgentSessionId: string) => { + if (sessionLinkSent || !user) return; + sessionLinkSent = true; + postSessionLinkEphemeral({ + accessToken, + channel, + user, + threadTs: replyThreadTs, + cloudAgentSessionId, + installation, + }).catch(err => { + console.error('[SlackBot:Webhook] Failed to send session link ephemeral:', err); + }); + }; + + // Process the message through Kilo Bot, with an internal timeout that fires + // before Vercel's maxDuration so we can send a graceful message to the user. + const botPromise = processKiloBotMessage(kiloInputText, teamId, { channelId: channel, threadTs: replyThreadTs, userId: user as string, messageTs: ts, + onCloudAgentSessionCreated, + }); + + const timeoutPromise = new Promise(resolve => { + setTimeout(() => resolve(null), ENDPOINT_TIMEOUT_MS); }); + + const result = await Promise.race([botPromise, timeoutPromise]); + + if (result === null) { + // Internal timeout fired before the bot finished + console.log('[SlackBot:Webhook] Internal endpoint timeout reached'); + + const timeoutMessage = markdownToSlackMrkdwn( + 'The Cloud Agent session is taking longer than expected. ' + + 'Our endpoint is shutting down, but the session is still running. ' + + 'Check the session link above to follow along.' + ); + await postSlackMessageByAccessToken(accessToken, { + channel, + text: timeoutMessage, + thread_ts: replyThreadTs, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: timeoutMessage, + }, + }, + ], + }); + + // Swap reactions to indicate we're done (from our side) + await Promise.all([ + removeSlackReactionByAccessToken(accessToken, { + channel, + timestamp: ts, + name: PROCESSING_REACTION, + }), + addSlackReactionByAccessToken(accessToken, { + channel, + timestamp: ts, + name: COMPLETE_REACTION, + }), + ]); + + console.log(`[SlackBot:Webhook] processSlackMessage (${event.type}) timed out gracefully`); + return; + } + const responseTimeMs = Date.now() - startTime; // Append dev user suffix if in dev environment @@ -276,18 +350,6 @@ async function processSlackMessage(event: AppMentionEvent | GenericMessageEvent, }), ]); - // If a cloud agent session was created, post an ephemeral button for the user to view it - if (result.cloudAgentSessionId && user) { - await postSessionLinkEphemeral({ - accessToken, - channel, - user, - threadTs: replyThreadTs, - cloudAgentSessionId: result.cloudAgentSessionId, - installation, - }); - } - // Log the request for admin debugging await logSlackBotRequest({ slackTeamId: teamId, diff --git a/src/lib/discord-bot.ts b/src/lib/discord-bot.ts index b16efaf7b..3dba6d832 100644 --- a/src/lib/discord-bot.ts +++ b/src/lib/discord-bot.ts @@ -214,7 +214,9 @@ async function spawnCloudAgentSession( export async function processDiscordBotMessage( userMessage: string, guildId: string, - discordEventContext?: DiscordEventContext + discordEventContext?: DiscordEventContext & { + onCloudAgentSessionCreated?: (cloudAgentSessionId: string) => void; + } ): Promise { console.log('[DiscordBot] processDiscordBotMessage started'); @@ -334,6 +336,7 @@ export async function processDiscordBotMessage( if (toolResult.sessionId) { cloudAgentSessionId = toolResult.sessionId; + discordEventContext?.onCloudAgentSessionCreated?.(toolResult.sessionId); } return { content: toolResult.response }; diff --git a/src/lib/slack-bot.ts b/src/lib/slack-bot.ts index 1743cfc86..20345a4b8 100644 --- a/src/lib/slack-bot.ts +++ b/src/lib/slack-bot.ts @@ -375,11 +375,15 @@ async function spawnCloudAgentSession( * This is the main entry point for generating AI responses with tool support. * @param userMessage The message from the user * @param teamId The Slack team ID to identify which integration to use + * @param slackEventContext Optional Slack event context with channel/thread info + * and an optional callback fired when a Cloud Agent session is created. */ export async function processKiloBotMessage( userMessage: string, teamId: string, - slackEventContext?: SlackEventContext + slackEventContext?: SlackEventContext & { + onCloudAgentSessionCreated?: (cloudAgentSessionId: string) => void; + } ): Promise { console.log('[SlackBot] processKiloBotMessage started with message:', userMessage); console.log('[SlackBot] Looking up Slack integration for team:', teamId); @@ -537,6 +541,7 @@ export async function processKiloBotMessage( console.log('[SlackBot] Tool result preview:', toolResult.response.slice(0, 100)); if (toolResult.sessionId) { cloudAgentSessionId = toolResult.sessionId; + slackEventContext?.onCloudAgentSessionCreated?.(toolResult.sessionId); } return {