From 8f792d0e39df413503869a4fe4eb824936491043 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 06:44:01 +0000 Subject: [PATCH 1/6] Improve bot comprehension for thread-reference task creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bot failed to understand messages like "füge diesen task hinzu" (add this task) when users referenced thread context. Two changes: 1. Agent instructions: Added explicit guidance and example for thread-reference task creation — when a user says "add this task" in a thread, the agent now knows to extract the task content from the thread context and incorporate any corrections from the user's message. 2. Fallback messages: Made language-aware so German users get a German fallback instead of the hardcoded English "I didn't quite understand that" message. https://claude.ai/code/session_01PoZNuRCfnFnfqK9cCAg6fD --- convex/agents/taskExtractor.ts | 9 +++++++++ convex/slack.ts | 26 +++++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/convex/agents/taskExtractor.ts b/convex/agents/taskExtractor.ts index 74a4901..ceccc39 100644 --- a/convex/agents/taskExtractor.ts +++ b/convex/agents/taskExtractor.ts @@ -130,9 +130,18 @@ You will be provided with a JSON context including \`workspaceId\`, \`userId\`, ## Conversational Rules - **Thread Context:** Read the FULL history including \`[Norbot]:\` messages. If the user says "create a task for this", create it based on the thread above. +- **Thread-Reference Task Creation (IMPORTANT):** When a user says things like "add this task", "create a task for this", "füge diesen task hinzu", "erstelle einen task dafür", or similar phrases that reference "this" — the task content comes from the **thread context** (the messages above), NOT from the user's current message. The user's message may contain corrections or additional details (like correct dates, priorities, etc.) that should be incorporated. **Always create the task immediately** from the thread content. - **URL Check:** If it's a BUG and a URL is missing, create the task anyway if the report is reasonable, then ask for the URL or steps to reproduce (ONE question). Only ask before creating if the report is too vague. - **Attachments:** Pass any provided attachment metadata to \`createTask\`. +## Example: Thread-Reference Task Creation +Thread context: + <@U456>: The date display on the PDF is wrong, both on the title page and in the header. It should be 22.03. to 04.04. + +User message: @norbot add this as a task. Correct would be 22 Mar - 4 Apr 2026 + +→ Create the task immediately! Title: "Fix date display on PDF title page and header". Description from thread context + user's correction. Do NOT ask "what task?" — the thread tells you. + ## Example: Answering Questions Thread context: <@U123>: There's a bug diff --git a/convex/slack.ts b/convex/slack.ts index fc4c975..38c9f15 100644 --- a/convex/slack.ts +++ b/convex/slack.ts @@ -606,9 +606,7 @@ Original text for task creation: ${originalText}`; // Send the agent's response directly // The agent handles everything: greetings, summaries, status updates, assignments, task creation - const responseText = - result.text || - "I didn't quite understand that. Could you provide more details?\n• What were you trying to do?\n• What happened instead?\n• Any error messages?"; + const responseText = result.text || getFallbackMessage(cleanText); await sendSlackMessage({ token: workspace.slackBotToken ?? "", @@ -798,7 +796,7 @@ Original text for task creation: ${originalText}`; }); const responseText = - result.text || "I'm not sure how to help with that. Could you clarify?"; + result.text || getFallbackMessage(args.text); await sendSlackMessage({ token: workspace.slackBotToken ?? "", @@ -953,6 +951,24 @@ async function sendSlackMessage(params: { return data; } +// Detect if text is likely German based on common German words +function looksGerman(text: string): boolean { + const lower = text.toLowerCase(); + const germanIndicators = [ + /\b(und|oder|nicht|auch|aber|mit|für|ein|eine|einen|einem|einer|des|dem|den|das|die|der|ist|sind|hat|haben|wird|werden|kann|können|auf|aus|bei|nach|von|vor|zu|zum|zur|über|unter|ich|du|er|sie|wir|ihr|diese|dieser|diesen|diesem|noch|schon|nur|sehr|hier|bitte|danke|guten|morgen|hallo)\b/, + /[äöüß]/, + ]; + return germanIndicators.some((re) => re.test(lower)); +} + +// Get fallback message in the appropriate language +function getFallbackMessage(userText: string): string { + if (looksGerman(userText)) { + return "Das habe ich leider nicht ganz verstanden. Kannst du etwas genauer beschreiben, was ich tun soll?"; + } + return "I didn't quite understand that. Could you describe what you'd like me to do in a bit more detail?"; +} + // Fetch all replies in a thread from Slack interface SlackThreadMessage { ts: string; @@ -1833,7 +1849,7 @@ Original text for task creation: ${originalText}`; }); const responseText = - result.text || "I'm not sure how to help with that. Could you clarify?"; + result.text || getFallbackMessage(args.text); await sendSlackMessage({ token: workspace.slackBotToken ?? "", From 12f789d5779a0f1e91b1ebd664a878c521a4938c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 06:46:31 +0000 Subject: [PATCH 2/6] Remove hardcoded German language detection from fallback Language detection should be handled by the LLM itself via the agent instructions, not by regex pattern matching. https://claude.ai/code/session_01PoZNuRCfnFnfqK9cCAg6fD --- convex/slack.ts | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/convex/slack.ts b/convex/slack.ts index 38c9f15..9502c48 100644 --- a/convex/slack.ts +++ b/convex/slack.ts @@ -606,7 +606,7 @@ Original text for task creation: ${originalText}`; // Send the agent's response directly // The agent handles everything: greetings, summaries, status updates, assignments, task creation - const responseText = result.text || getFallbackMessage(cleanText); + const responseText = result.text || getFallbackMessage(); await sendSlackMessage({ token: workspace.slackBotToken ?? "", @@ -796,7 +796,7 @@ Original text for task creation: ${originalText}`; }); const responseText = - result.text || getFallbackMessage(args.text); + result.text || getFallbackMessage(); await sendSlackMessage({ token: workspace.slackBotToken ?? "", @@ -951,21 +951,8 @@ async function sendSlackMessage(params: { return data; } -// Detect if text is likely German based on common German words -function looksGerman(text: string): boolean { - const lower = text.toLowerCase(); - const germanIndicators = [ - /\b(und|oder|nicht|auch|aber|mit|für|ein|eine|einen|einem|einer|des|dem|den|das|die|der|ist|sind|hat|haben|wird|werden|kann|können|auf|aus|bei|nach|von|vor|zu|zum|zur|über|unter|ich|du|er|sie|wir|ihr|diese|dieser|diesen|diesem|noch|schon|nur|sehr|hier|bitte|danke|guten|morgen|hallo)\b/, - /[äöüß]/, - ]; - return germanIndicators.some((re) => re.test(lower)); -} - -// Get fallback message in the appropriate language -function getFallbackMessage(userText: string): string { - if (looksGerman(userText)) { - return "Das habe ich leider nicht ganz verstanden. Kannst du etwas genauer beschreiben, was ich tun soll?"; - } +// Fallback when agent returns empty response +function getFallbackMessage(): string { return "I didn't quite understand that. Could you describe what you'd like me to do in a bit more detail?"; } @@ -1849,7 +1836,7 @@ Original text for task creation: ${originalText}`; }); const responseText = - result.text || getFallbackMessage(args.text); + result.text || getFallbackMessage(); await sendSlackMessage({ token: workspace.slackBotToken ?? "", From 0d2065c510bba9f4c4fcd4c7195636480602d06a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 06:54:49 +0000 Subject: [PATCH 3/6] Restructure agent prompt to separate thread context from tool parameters The thread context was buried inside a "Context (use these values when calling tools)" block, mixed with JSON metadata like source and projectsMapping. The LLM treated it as tool parameter data and missed the actual conversation content. Now uses clear markdown sections: "Tool parameters", "Thread context", and "User message" so the LLM can distinguish between metadata and conversation. https://claude.ai/code/session_01PoZNuRCfnFnfqK9cCAg6fD --- convex/slack.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/convex/slack.ts b/convex/slack.ts index 9502c48..ea6523e 100644 --- a/convex/slack.ts +++ b/convex/slack.ts @@ -585,11 +585,12 @@ export const handleAppMention = internalAction({ }; // Build context for the agent with all required parameters for tools - const contextInfo = `Context (use these values when calling tools): + const contextInfo = `## Tool parameters - source: ${JSON.stringify(sourceContext)} -- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${attachmentsInfo}${projectsInfo}${threadContext} - -User message: ${cleanText} +- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${attachmentsInfo}${projectsInfo} +${threadContext} +## User message +${cleanText} Original text for task creation: ${originalText}`; @@ -771,11 +772,12 @@ export const handleThreadReply = internalAction({ } // Build context for follow-up with full context - const contextInfo = `Context (use these values when calling tools): + const contextInfo = `## Tool parameters - source: ${JSON.stringify(sourceContext)} -- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${attachmentsInfo}${projectsInfo}${threadContext} - -User follow-up message: ${args.text} +- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${attachmentsInfo}${projectsInfo} +${threadContext} +## User follow-up message +${args.text} Original text for task creation: ${originalText}`; @@ -1018,7 +1020,7 @@ function formatThreadForContext( const truncated = formatted.length > 2000 ? formatted.slice(-2000) + "\n[...truncated]" : formatted; - return `\n\nThread context (previous messages in this thread):\n${truncated}`; + return `\n## Thread context (previous messages in this thread — use this to understand what the user is referring to)\n${truncated}`; } // =========================================== @@ -1817,11 +1819,12 @@ export const handleAssistantMessage = internalAction({ channelProjectInfo = "\n- channelDefaultProject: none - Use keyword matching or ask which project"; } - const contextInfo = `Context (use these values when calling tools): + const contextInfo = `## Tool parameters - source: ${JSON.stringify(sourceContext)} -- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${projectsInfo}${threadContext} - -User message: ${args.text} +- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${projectsInfo} +${threadContext} +## User message +${args.text} Original text for task creation: ${originalText}`; From b38d687378a33db001ce617a0098ab3825e09236 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 08:23:11 +0000 Subject: [PATCH 4/6] Add deduplication and fix dropped thread context in reply handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleThreadReply: Add event deduplication (was missing, could cause duplicate agent calls and double usage counting) - handleThreadReply: Continue agent conversation even when status is not "active" — previously a non-active conversation caused the bot to silently drop the message, losing all thread context - handleAssistantMessage: Add event deduplication (same issue) https://claude.ai/code/session_01PoZNuRCfnFnfqK9cCAg6fD --- convex/slack.ts | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/convex/slack.ts b/convex/slack.ts index ea6523e..8c947fd 100644 --- a/convex/slack.ts +++ b/convex/slack.ts @@ -653,6 +653,18 @@ export const handleThreadReply = internalAction({ threadTs: v.string(), }, handler: async (ctx, args) => { + // Deduplication: Check if we already processed this event + const alreadyProcessed = await ctx.runQuery(internal.slack.isEventProcessed, { + eventTs: args.ts, + }); + if (alreadyProcessed) return; + + const marked = await ctx.runMutation(internal.slack.markEventProcessed, { + eventTs: args.ts, + eventType: "thread_reply", + }); + if (!marked) return; + // Get workspace const workspace = await ctx.runQuery(internal.slack.getWorkspaceBySlackTeam, { slackTeamId: args.teamId, @@ -667,8 +679,8 @@ export const handleThreadReply = internalAction({ slackThreadTs: args.threadTs, }); - if (conversation && conversation.status === "active") { - // Continue the agent conversation + if (conversation) { + // Continue the agent conversation (reuse thread if active, create new if not) try { // Check AI usage limits const usageCheck = await ctx.runQuery(internal.ai.checkUsageInternal, { @@ -781,11 +793,15 @@ ${args.text} Original text for task creation: ${originalText}`; - // Continue on the existing agent thread + // Reuse agent thread if active, otherwise create new one + const threadId = conversation.status === "active" + ? conversation.agentThreadId + : (await norbotAgent.createThread(ctx, {})).threadId; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await norbotAgent.generateText( ctx, - { threadId: conversation.agentThreadId }, + { threadId }, { messages: [{ role: "user" as const, content: contextInfo }], maxSteps: 5, @@ -812,7 +828,7 @@ Original text for task creation: ${originalText}`; workspaceId: workspace._id, slackChannelId: args.channelId, slackThreadTs: args.threadTs, - agentThreadId: conversation.agentThreadId, + agentThreadId: threadId, status: "active", originalText, originalMessageTs, @@ -1694,6 +1710,18 @@ export const handleAssistantMessage = internalAction({ threadTs: v.optional(v.string()), }, handler: async (ctx, args) => { + // Deduplication: Check if we already processed this event + const alreadyProcessed = await ctx.runQuery(internal.slack.isEventProcessed, { + eventTs: args.ts, + }); + if (alreadyProcessed) return; + + const marked = await ctx.runMutation(internal.slack.markEventProcessed, { + eventTs: args.ts, + eventType: "assistant_message", + }); + if (!marked) return; + const workspace = await ctx.runQuery(internal.slack.getWorkspaceBySlackTeam, { slackTeamId: args.teamId, }); From ea5c8625e9e1a7589c314b73bbda55e9a2392858 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 09:47:18 +0000 Subject: [PATCH 5/6] Fix dual-fire race, thread context truncation, and conversation persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Dual-fire: Revert delay hack — the existing event deduplication (markEventProcessed) is atomic and sufficient to prevent both handleAppMention and handleThreadReply from processing the same message. 2. Thread context: Remove .slice(-15) and 2000-char truncation from formatThreadForContext. The Slack API already limits to 50 messages — that's enough of a natural limit. Better to give the agent full context than risk losing the parent message (original problem statement). 3. Conversation persistence: Save conversation BEFORE the agent call instead of after. If the agent crashes, the conversation record still exists so follow-up messages are properly routed to the agent. https://claude.ai/code/session_01PoZNuRCfnFnfqK9cCAg6fD --- convex/slack.ts | 39 ++++++++++++++++----------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/convex/slack.ts b/convex/slack.ts index 8c947fd..54a24b3 100644 --- a/convex/slack.ts +++ b/convex/slack.ts @@ -594,6 +594,20 @@ ${cleanText} Original text for task creation: ${originalText}`; + // Save conversation BEFORE agent call so follow-ups work even if the agent fails + await ctx.runMutation(internal.slack.upsertAgentConversation, { + workspaceId: workspace._id, + slackChannelId: args.channelId, + slackThreadTs: args.threadTs, + agentThreadId: threadId, + status: "active", + originalText, + originalMessageTs, + originalAttachments: storedAttachments, + lastUserText: cleanText, + lastUserMessageTs: args.ts, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await norbotAgent.generateText(ctx, { threadId }, { messages: [{ role: "user" as const, content: contextInfo }], @@ -606,7 +620,6 @@ Original text for task creation: ${originalText}`; }); // Send the agent's response directly - // The agent handles everything: greetings, summaries, status updates, assignments, task creation const responseText = result.text || getFallbackMessage(); await sendSlackMessage({ @@ -615,20 +628,6 @@ Original text for task creation: ${originalText}`; threadTs: args.threadTs, text: responseText, }); - - // Save conversation for thread continuity (so we can respond to follow-ups) - await ctx.runMutation(internal.slack.upsertAgentConversation, { - workspaceId: workspace._id, - slackChannelId: args.channelId, - slackThreadTs: args.threadTs, - agentThreadId: threadId, - status: "active", - originalText, - originalMessageTs, - originalAttachments: storedAttachments, - lastUserText: cleanText, - lastUserMessageTs: args.ts, - }); } catch (error) { console.error("Agent error:", error); await sendSlackMessage({ @@ -1017,9 +1016,7 @@ function formatThreadForContext( currentTs: string ): string { // Filter out the current message, keep bot messages for conversation context - const relevantMessages = messages - .filter((m) => m.ts !== currentTs) - .slice(-15); + const relevantMessages = messages.filter((m) => m.ts !== currentTs); if (relevantMessages.length === 0) { return ""; @@ -1032,11 +1029,7 @@ function formatThreadForContext( : `<@${m.user}>: ${m.text}`) .join("\n"); - // Cap at ~2000 chars to avoid token bloat - const truncated = - formatted.length > 2000 ? formatted.slice(-2000) + "\n[...truncated]" : formatted; - - return `\n## Thread context (previous messages in this thread — use this to understand what the user is referring to)\n${truncated}`; + return `\n## Thread context (previous messages in this thread — use this to understand what the user is referring to)\n${formatted}`; } // =========================================== From 1b20089ed7e562a9c237e0c36012d353d7bb60d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 12:23:28 +0000 Subject: [PATCH 6/6] Add stop command to stop bot from following a thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now say "@norbot stop" (or "stopp", "halt") to make the bot stop following a thread. The conversation status is set to "stopped" and handleThreadReply skips stopped conversations. To resume, simply @mention the bot again with a new message — handleAppMention creates a fresh agent thread and sets the conversation back to "active". https://claude.ai/code/session_01PoZNuRCfnFnfqK9cCAg6fD --- convex/slack.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/convex/slack.ts b/convex/slack.ts index 54a24b3..e7cf672 100644 --- a/convex/slack.ts +++ b/convex/slack.ts @@ -469,6 +469,31 @@ export const handleAppMention = internalAction({ return; } + // Handle stop command — stop following this thread + if (/^(stop|stopp|halt)$/i.test(cleanText)) { + const existingConvo = await ctx.runQuery(internal.slack.getAgentConversation, { + workspaceId: workspace._id, + slackChannelId: args.channelId, + slackThreadTs: args.threadTs, + }); + if (existingConvo) { + await ctx.runMutation(internal.slack.upsertAgentConversation, { + workspaceId: workspace._id, + slackChannelId: args.channelId, + slackThreadTs: args.threadTs, + agentThreadId: existingConvo.agentThreadId, + status: "stopped", + }); + } + await sendSlackMessage({ + token: workspace.slackBotToken ?? "", + channelId: args.channelId, + threadTs: args.threadTs, + text: "Ok, I'll stop following this thread. Mention me again to resume.", + }); + return; + } + // Download any attached files from Slack let attachments: Array<{ storageId: string; @@ -678,7 +703,7 @@ export const handleThreadReply = internalAction({ slackThreadTs: args.threadTs, }); - if (conversation) { + if (conversation && conversation.status !== "stopped") { // Continue the agent conversation (reuse thread if active, create new if not) try { // Check AI usage limits @@ -1471,7 +1496,7 @@ export const upsertAgentConversation = internalMutation({ slackChannelId: v.string(), slackThreadTs: v.string(), agentThreadId: v.string(), - status: v.union(v.literal("active"), v.literal("completed")), + status: v.union(v.literal("active"), v.literal("completed"), v.literal("stopped")), originalText: v.optional(v.string()), originalMessageTs: v.optional(v.string()), originalAttachments: v.optional(