From 7e953577ca5561718eb7b3be8a29dedf3e66ffea Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:36:12 +0000 Subject: [PATCH 1/2] fix: launch Fast automation suggestions on Teams and Telegram --- .../__tests__/fast-agent-service.test.ts | 74 ++++++++------- .../server/fast-agent/fast-agent-prompt.ts | 2 +- .../server/fast-agent/fast-agent-service.ts | 7 +- .../lib/fast-agent-parent-event.test.ts | 92 +++++++++++++++++++ .../src/server/lib/fast-agent-parent-event.ts | 82 ++++++++++++++++- .../lib/fast-automation-suggestions.test.ts | 92 +++++++++++++++++++ .../server/lib/fast-automation-suggestions.ts | 83 ++++++++++++++++- 7 files changed, 386 insertions(+), 46 deletions(-) diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index d5f803155..ce25ce75f 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -1919,42 +1919,46 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { ); }); - it('passes structured suggestions through an automation closeout', async () => { - const adapter = callbacks(); - const suggestions = [ - { - title: 'Investigate checkout latency', - brief: 'Trace the slow payment-provider requests.', - }, - ]; - mocks.generateText.mockImplementation( - async (_params, _session, options) => { - await options.onSessionReady('opencode-session-1'); - await expect( - invokeTool(nativeToolNames.sendChatReply, { - purpose: 'closeout', - message: 'Checkout latency increased this week.', - suggestions, - }), - ).resolves.toMatchObject({ success: true, closed: true }); - return ''; - }, - ); + it.each(['slack', 'discord', 'teams', 'telegram'] as const)( + 'passes structured suggestions through a %s automation closeout', + async (surface) => { + const adapter = callbacks(); + const suggestions = [ + { + title: 'Investigate checkout latency', + brief: 'Trace the slow payment-provider requests.', + }, + ]; + mocks.generateText.mockImplementation( + async (_params, _session, options) => { + await options.onSessionReady('opencode-session-1'); + await expect( + invokeTool(nativeToolNames.sendChatReply, { + purpose: 'closeout', + message: 'Checkout latency increased this week.', + suggestions, + }), + ).resolves.toMatchObject({ success: true, closed: true }); + return ''; + }, + ); - await answerFastAgentQuestion({ - ...baseParams, - adapter, - turnSource: 'platform_event', - platformEventKind: 'automation', - platformEventVisibility: 'required', - }); + await answerFastAgentQuestion({ + ...baseParams, + conversation: { ...baseParams.conversation, surface }, + adapter, + turnSource: 'platform_event', + platformEventKind: 'automation', + platformEventVisibility: 'required', + }); - expect(adapter.postReply).toHaveBeenCalledWith({ - purpose: 'closeout', - message: 'Checkout latency increased this week.', - suggestions, - }); - }); + expect(adapter.postReply).toHaveBeenCalledWith({ + purpose: 'closeout', + message: 'Checkout latency increased this week.', + suggestions, + }); + }, + ); it('rejects structured suggestions outside automation reports', async () => { mocks.generateText.mockImplementation( @@ -1969,7 +1973,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { ).resolves.toEqual({ success: false, error: - 'Launchable suggestions are available only on Slack or Discord automation closeouts.', + 'Launchable suggestions are available only on chat automation closeouts.', }); return ''; }, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index 178d794af..72a0e9467 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts @@ -258,7 +258,7 @@ ${ ${ platformEventKind === 'automation' ? `- Execute the automation prompt now. Use integrations directly when sufficient, and launch a task only when repository or workspace execution is actually required. The configured model is a delegated-task default, not the Fast inference model. -- When the automation asks for launchable suggested tasks and this is a Slack or Discord report, put each concrete follow-up in the closeout's \`suggestions\` array. Keep the report summary in \`message\`; do not render suggestion cards or reaction instructions as inline prose because the delivery layer adds them. +- When the automation asks for launchable suggested tasks and this is a Slack, Discord, Teams, or Telegram report, put each concrete follow-up in the closeout's \`suggestions\` array. Keep the report summary in \`message\`; do not render suggestion cards or launch instructions as inline prose because the delivery layer adds them. - If launchable suggestions are unavailable on the current surface, keep follow-ups as ordinary report text and do not promise reaction-triggered launching. ` : '' diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index d6429cf7f..33118bd5f 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -1417,13 +1417,14 @@ export async function answerFastAgentQuestion({ (args.purpose !== 'closeout' || !platformEvent || platformEventKind !== 'automation' || - (conversation.surface !== 'slack' && - conversation.surface !== 'discord')) + !['slack', 'discord', 'teams', 'telegram'].includes( + conversation.surface, + )) ) { return { success: false, error: - 'Launchable suggestions are available only on Slack or Discord automation closeouts.', + 'Launchable suggestions are available only on chat automation closeouts.', }; } if ( diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 2628a0fb1..56e9a7a28 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -33,6 +33,8 @@ const mocks = vi.hoisted(() => ({ appendSuggestionInstruction: vi.fn((message: string) => message), postSlackSuggestions: vi.fn(), postDiscordSuggestions: vi.fn(), + postTeamsSuggestions: vi.fn(), + postTelegramSuggestions: vi.fn(), })); vi.mock('@roomote/redis', async (importOriginal) => { @@ -166,6 +168,8 @@ vi.mock('./fast-automation-suggestions', () => ({ appendFastAutomationSuggestionInstruction: mocks.appendSuggestionInstruction, postFastAutomationSuggestionsToSlack: mocks.postSlackSuggestions, postFastAutomationSuggestionsToDiscord: mocks.postDiscordSuggestions, + postFastAutomationSuggestionsToTeams: mocks.postTeamsSuggestions, + postFastAutomationSuggestionsToTelegram: mocks.postTelegramSuggestions, })); import { deliverFastAgentParentEvent } from './fast-agent-parent-event'; @@ -233,6 +237,8 @@ describe('deliverFastAgentParentEvent', () => { mocks.resolveUserMcpServerConfigs.mockResolvedValue({}); mocks.postSlackSuggestions.mockResolvedValue(undefined); mocks.postDiscordSuggestions.mockResolvedValue(undefined); + mocks.postTeamsSuggestions.mockResolvedValue(undefined); + mocks.postTelegramSuggestions.mockResolvedValue(undefined); mocks.setPendingPrReviewAction.mockResolvedValue(undefined); mocks.attachPendingPrReviewActionMessage.mockResolvedValue({ attached: true, @@ -858,6 +864,92 @@ describe('deliverFastAgentParentEvent', () => { expect(mocks.teamsPostMessage).not.toHaveBeenCalled(); }); + it.each([ + { + surface: 'teams' as const, + workspaceId: 'tenant-1', + channelId: 'teams-channel-1', + threadId: 'teams-root-1', + rootMessageId: 'teams-root-1', + postSuggestions: mocks.postTeamsSuggestions, + }, + { + surface: 'teams' as const, + workspaceId: 'tenant-1', + channelId: 'teams-channel-1', + threadId: undefined, + rootMessageId: undefined, + postSuggestions: mocks.postTeamsSuggestions, + }, + { + surface: 'telegram' as const, + workspaceId: 'telegram-chat-1', + channelId: 'telegram-chat-1', + threadId: undefined, + rootMessageId: undefined, + postSuggestions: mocks.postTelegramSuggestions, + }, + ])( + 'posts structured suggestions beneath a Fast $surface automation report', + async ({ + surface, + workspaceId, + channelId, + threadId, + rootMessageId, + postSuggestions, + }) => { + const suggestions = [ + { title: 'Verify retry behavior', brief: 'Exercise the failure path.' }, + ]; + mocks.answerQuestion.mockImplementationOnce( + async ({ + adapter, + }: { + adapter: { postReply: (reply: unknown) => unknown }; + }) => + adapter.postReply({ + purpose: 'closeout', + message: 'Retry failures increased.', + suggestions, + }), + ); + + await deliverFastAgentParentEvent({ + parent: { + ...parent, + conversation: { + surface, + workspaceId, + conversationId: `${surface}-occurrence-1`, + replyTarget: { + channelId, + ...(threadId ? { threadId } : {}), + }, + }, + }, + event: { + type: 'automation_triggered', + eventId: `${surface}-occurrence-1`, + automationId: 'automation-1', + automationName: 'Retry scan', + prompt: 'Find actionable retry failures.', + trigger: 'schedule', + ...(rootMessageId ? { rootMessageId } : {}), + }, + }); + + expect(postSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + channelId, + eventId: `${surface}-occurrence-1`, + createdByUserId: 'u1', + suggestions, + }), + ); + }, + ); + it("refreshes Teams routing from the persisted session's current channel", async () => { const fallbackConversation = { surface: 'teams' as const, diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index fc4384a9b..a0cb12acf 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -58,6 +58,8 @@ import { appendFastAutomationSuggestionInstruction, postFastAutomationSuggestionsToDiscord, postFastAutomationSuggestionsToSlack, + postFastAutomationSuggestionsToTeams, + postFastAutomationSuggestionsToTelegram, } from './fast-automation-suggestions'; import { @@ -1068,12 +1070,25 @@ async function createTeamsFastAgentParentTurn(params: { conversation, serviceUrl, }), - postReply: async ({ message, imageArtifactIds = [], kickoff }) => { + postReply: async ({ + message, + imageArtifactIds = [], + suggestions = [], + kickoff, + }) => { const images = await buildSelectedImages({ artifactIds: imageArtifactIds, event: params.event, }); - const text = `${message}\n\n${buildFastSessionReplyFooterText({ provider: 'teams', sessionId: params.parent.sessionId })}`; + const reportMessage = + params.event.type === 'automation_triggered' && !kickoff + ? appendFastAutomationSuggestionInstruction( + message, + 'teams', + suggestions.length > 0, + ) + : message; + const text = `${reportMessage}\n\n${buildFastSessionReplyFooterText({ provider: 'teams', sessionId: params.parent.sessionId })}`; if ( params.event.type === 'automation_triggered' && params.event.rootMessageId && @@ -1092,6 +1107,19 @@ async function createTeamsFastAgentParentTurn(params: { conversation, messageId: params.event.rootMessageId, }); + if (suggestions.length > 0) { + await postFastAutomationSuggestionsToTeams({ + provider, + channelId: conversation.replyTarget.channelId, + serviceUrl, + ...(conversation.replyTarget.threadId + ? { threadId: conversation.replyTarget.threadId } + : {}), + eventId: params.event.eventId, + createdByUserId: session.userId, + suggestions, + }); + } params.onReplyPosted(); return { messageId: params.event.rootMessageId }; } @@ -1108,6 +1136,23 @@ async function createTeamsFastAgentParentTurn(params: { textFormat: 'markdown', images, }); + if ( + params.event.type === 'automation_triggered' && + !kickoff && + suggestions.length > 0 + ) { + await postFastAutomationSuggestionsToTeams({ + provider, + channelId: conversation.replyTarget.channelId, + serviceUrl, + ...(conversation.replyTarget.threadId + ? { threadId: conversation.replyTarget.threadId } + : {}), + eventId: params.event.eventId, + createdByUserId: session.userId, + suggestions, + }); + } await recordFastAgentConversationMessageBestEffort({ sessionId: session.id, conversation, @@ -1151,20 +1196,49 @@ async function createTelegramFastAgentParentTurn(params: { userId: session.userId, conversation, }), - postReply: async ({ message, imageArtifactIds = [] }) => { + postReply: async ({ + message, + imageArtifactIds = [], + suggestions = [], + kickoff, + }) => { const images = await buildSelectedImages({ artifactIds: imageArtifactIds, event: params.event, }); + const reportMessage = + params.event.type === 'automation_triggered' && !kickoff + ? appendFastAutomationSuggestionInstruction( + message, + 'telegram', + suggestions.length > 0, + ) + : message; const posted = await provider.postMessage({ channelId: conversation.replyTarget.channelId, ...(conversation.replyTarget.threadId ? { threadId: conversation.replyTarget.threadId } : {}), - text: `${message}\n\n${buildFastSessionReplyFooterText({ provider: 'telegram', sessionId: params.parent.sessionId })}`, + text: `${reportMessage}\n\n${buildFastSessionReplyFooterText({ provider: 'telegram', sessionId: params.parent.sessionId })}`, textFormat: 'markdown', images, }); + if ( + params.event.type === 'automation_triggered' && + !kickoff && + suggestions.length > 0 + ) { + await postFastAutomationSuggestionsToTelegram({ + provider, + channelId: conversation.replyTarget.channelId, + ...(conversation.replyTarget.threadId + ? { threadId: conversation.replyTarget.threadId } + : {}), + eventId: params.event.eventId, + createdByUserId: session.userId, + suggestions, + }); + } params.onReplyPosted(); return { messageId: posted.messageId }; }, diff --git a/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts b/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts index 147e0779f..7aee390ec 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts @@ -11,6 +11,8 @@ import { appendFastAutomationSuggestionInstruction, postFastAutomationSuggestionsToDiscord, postFastAutomationSuggestionsToSlack, + postFastAutomationSuggestionsToTeams, + postFastAutomationSuggestionsToTelegram, } from './fast-automation-suggestions'; describe('Fast automation suggestions', () => { @@ -130,6 +132,93 @@ describe('Fast automation suggestions', () => { }); }); + it('persists and tracks reaction-launchable Teams suggestion cards', async () => { + const user = await userFactory.create(); + const postMessage = vi.fn().mockResolvedValue({ + provider: 'teams', + channelId: 'conversation-1', + threadId: 'thread-1', + messageId: 'message-1', + }); + + await postFastAutomationSuggestionsToTeams({ + provider: { postMessage }, + channelId: 'conversation-1', + serviceUrl: 'https://smba.example.com/amer/', + threadId: 'thread-1', + eventId: 'automation-teams', + createdByUserId: user.id, + suggestions: [ + { title: 'Verify Teams retries', brief: 'Exercise the failure path.' }, + ], + }); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'conversation-1', + serviceUrl: 'https://smba.example.com/amer/', + threadId: 'thread-1', + }), + ); + const [tracked] = await db + .select() + .from(trackedMessages) + .where(eq(trackedMessages.surface, 'teams')); + expect(tracked).toMatchObject({ + channelId: 'conversation-1', + messageTs: 'message-1', + threadTs: 'thread-1', + createdByUserId: user.id, + metadata: expect.objectContaining({ launchRouting: 'router' }), + }); + }); + + it('persists and tracks button-launchable Telegram suggestion cards', async () => { + const user = await userFactory.create(); + const postMessage = vi.fn().mockResolvedValue({ + provider: 'telegram', + channelId: 'chat-1', + messageId: 'message-1', + }); + + await postFastAutomationSuggestionsToTelegram({ + provider: { postMessage }, + channelId: 'chat-1', + eventId: 'automation-telegram', + createdByUserId: user.id, + suggestions: [ + { + title: 'Verify Telegram retries', + brief: 'Exercise the failure path.', + }, + ], + }); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'chat-1', + buttons: [ + [ + { + text: 'Start', + callbackData: expect.stringMatching(/^idea:/), + }, + ], + ], + }), + ); + const [tracked] = await db + .select() + .from(trackedMessages) + .where(eq(trackedMessages.surface, 'telegram')); + expect(tracked).toMatchObject({ + channelId: 'chat-1', + messageTs: 'message-1', + createdByUserId: user.id, + metadata: expect.objectContaining({ launchRouting: 'router' }), + }); + }); + it('serializes concurrent persistence retries for one automation event', async () => { const user = await userFactory.create(); const postMessage = vi.fn().mockResolvedValue('400.001'); @@ -166,5 +255,8 @@ describe('Fast automation suggestions', () => { expect( appendFastAutomationSuggestionInstruction('Report', 'slack', false), ).toBe('Report'); + expect( + appendFastAutomationSuggestionInstruction('Report', 'telegram', true), + ).toContain('Tap Start'); }); }); diff --git a/packages/sdk/src/server/lib/fast-automation-suggestions.ts b/packages/sdk/src/server/lib/fast-automation-suggestions.ts index c0f984767..b74b1dd2c 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.ts @@ -1,6 +1,8 @@ import { createHash } from 'node:crypto'; import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; +import type { TeamsCommunicationProvider } from '@roomote/communication/teams-provider'; +import type { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; import { and, asc, @@ -28,7 +30,7 @@ type PersistedFastAutomationSuggestion = FastAutomationSuggestion & { export function appendFastAutomationSuggestionInstruction( message: string, - surface: 'slack' | 'discord', + surface: 'slack' | 'discord' | 'teams' | 'telegram', hasSuggestions: boolean, ): string { if (!hasSuggestions) return message; @@ -36,7 +38,9 @@ export function appendFastAutomationSuggestionInstruction( const instruction = surface === 'slack' ? "Want me to take one of these on? React with a :thumbsup: on a suggested task below and I'll start it." - : "Want me to take one of these on? React with a 👍 on a suggested task below and I'll start it."; + : surface === 'telegram' + ? "Want me to take one of these on? Tap Start on a suggested task below and I'll launch it." + : "Want me to take one of these on? React with a 👍 on a suggested task below and I'll start it."; return message.includes(instruction) ? message : `${message}\n\n${instruction}`; @@ -148,7 +152,7 @@ function formatSuggestion( } async function trackSuggestion(params: { - surface: 'slack' | 'discord'; + surface: 'slack' | 'discord' | 'teams' | 'telegram'; channelId: string; messageId: string; threadId?: string; @@ -253,3 +257,76 @@ export async function postFastAutomationSuggestionsToDiscord(params: { }); } } + +export async function postFastAutomationSuggestionsToTeams(params: { + provider: Pick; + channelId: string; + serviceUrl: string; + threadId?: string; + eventId: string; + createdByUserId: string; + suggestions: FastAutomationSuggestion[]; +}): Promise { + const suggestions = await persistFastAutomationSuggestions(params); + const trackedWorkItemIds = await findTrackedSuggestionWorkItemIds({ + surface: 'teams', + workItemIds: suggestions.map((suggestion) => suggestion.id), + }); + for (const suggestion of suggestions) { + if (trackedWorkItemIds.has(suggestion.id)) continue; + + const posted = await params.provider.postMessage({ + channelId: params.channelId, + serviceUrl: params.serviceUrl, + ...(params.threadId + ? { threadId: params.threadId, replyToMessageId: params.threadId } + : {}), + text: formatSuggestion(suggestion), + textFormat: 'markdown', + }); + await trackSuggestion({ + surface: 'teams', + channelId: posted.channelId, + messageId: posted.messageId, + ...(posted.threadId ? { threadId: posted.threadId } : {}), + workItemId: suggestion.id, + createdByUserId: params.createdByUserId, + eventId: params.eventId, + }); + } +} + +export async function postFastAutomationSuggestionsToTelegram(params: { + provider: Pick; + channelId: string; + threadId?: string; + eventId: string; + createdByUserId: string; + suggestions: FastAutomationSuggestion[]; +}): Promise { + const suggestions = await persistFastAutomationSuggestions(params); + const trackedWorkItemIds = await findTrackedSuggestionWorkItemIds({ + surface: 'telegram', + workItemIds: suggestions.map((suggestion) => suggestion.id), + }); + for (const suggestion of suggestions) { + if (trackedWorkItemIds.has(suggestion.id)) continue; + + const posted = await params.provider.postMessage({ + channelId: params.channelId, + ...(params.threadId ? { threadId: params.threadId } : {}), + text: formatSuggestion(suggestion), + textFormat: 'markdown', + buttons: [[{ text: 'Start', callbackData: `idea:${suggestion.id}` }]], + }); + await trackSuggestion({ + surface: 'telegram', + channelId: posted.channelId, + messageId: posted.messageId, + ...(posted.threadId ? { threadId: posted.threadId } : {}), + workItemId: suggestion.id, + createdByUserId: params.createdByUserId, + eventId: params.eventId, + }); + } +} From c8c53b98d082d7c3089515273cd78b7ad7996413 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:50:47 +0000 Subject: [PATCH 2/2] fix: claim automation suggestion sends before posting --- .../lib/fast-automation-suggestions.test.ts | 69 +++++++++++++++ .../server/lib/fast-automation-suggestions.ts | 85 ++++++++++++++++--- 2 files changed, 144 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts b/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts index 7aee390ec..7f879f467 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts @@ -219,6 +219,75 @@ describe('Fast automation suggestions', () => { }); }); + it.each([ + { + surface: 'teams' as const, + post: postFastAutomationSuggestionsToTeams, + providerResult: { + provider: 'teams' as const, + channelId: 'conversation-retry', + messageId: 'teams-message-retry', + }, + extra: { + serviceUrl: 'https://smba.example.com/amer/', + }, + }, + { + surface: 'telegram' as const, + post: postFastAutomationSuggestionsToTelegram, + providerResult: { + provider: 'telegram' as const, + channelId: 'conversation-retry', + messageId: 'telegram-message-retry', + }, + extra: {}, + }, + ])( + 'does not duplicate $surface cards when the provider outcome is unknown', + async ({ post, providerResult, extra }) => { + const user = await userFactory.create(); + const postMessage = vi + .fn() + .mockRejectedValueOnce(new Error('provider response lost')) + .mockResolvedValue(providerResult); + const params = { + provider: { postMessage }, + channelId: 'conversation-retry', + eventId: `automation-retry-${providerResult.provider}`, + createdByUserId: user.id, + suggestions: [ + { + title: 'Retry-safe delivery', + brief: 'Do not post this card twice.', + }, + ], + ...extra, + }; + + await expect(post(params as never)).rejects.toThrow( + 'provider response lost', + ); + await post(params as never); + + expect(postMessage).toHaveBeenCalledOnce(); + const [claim] = await db + .select() + .from(trackedMessages) + .where( + and( + eq(trackedMessages.surface, providerResult.provider), + eq(trackedMessages.channelId, 'conversation-retry'), + ), + ); + expect(claim).toMatchObject({ + channelId: 'conversation-retry', + messageTs: null, + createdByUserId: user.id, + metadata: expect.objectContaining({ launchRouting: 'router' }), + }); + }, + ); + it('serializes concurrent persistence retries for one automation event', async () => { const user = await userFactory.create(); const postMessage = vi.fn().mockResolvedValue('400.001'); diff --git a/packages/sdk/src/server/lib/fast-automation-suggestions.ts b/packages/sdk/src/server/lib/fast-automation-suggestions.ts index b74b1dd2c..df36c1599 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.ts @@ -12,6 +12,7 @@ import { inArray, registerTrackedSuggestionCards, sql, + trackedMessages, workItems, } from '@roomote/db/server'; import { @@ -176,6 +177,56 @@ async function trackSuggestion(params: { ]); } +async function claimSuggestionSend(params: { + surface: 'teams' | 'telegram'; + channelId: string; + threadId?: string; + workItemId: string; + createdByUserId: string; + eventId: string; +}): Promise { + const [claim] = await db + .insert(trackedMessages) + .values({ + surface: params.surface, + kind: 'suggestion_card', + dedupeKey: `${params.surface}:${params.channelId}:${params.eventId}:${params.workItemId}`, + channelId: params.channelId, + ...(params.threadId ? { threadTs: params.threadId } : {}), + workItemId: params.workItemId, + createdByUserId: params.createdByUserId, + metadata: { + suggestionType: 'suggested_tasks', + suggestionKey: `${params.eventId}:${params.workItemId}`, + suggestionGroupKey: params.eventId, + launchRouting: 'router', + }, + }) + .onConflictDoNothing({ + target: [trackedMessages.kind, trackedMessages.dedupeKey], + }) + .returning({ id: trackedMessages.id }); + return claim?.id ?? null; +} + +async function finalizeSuggestionSend(params: { + claimId: string; + channelId: string; + messageId: string; + threadId?: string; +}): Promise { + await db + .update(trackedMessages) + .set({ + dedupeKey: `${params.channelId}:${params.messageId}`, + channelId: params.channelId, + messageTs: params.messageId, + threadTs: params.threadId ?? null, + updatedAt: new Date(), + }) + .where(eq(trackedMessages.id, params.claimId)); +} + export async function postFastAutomationSuggestionsToSlack(params: { slack: Pick; channelId: string; @@ -275,6 +326,16 @@ export async function postFastAutomationSuggestionsToTeams(params: { for (const suggestion of suggestions) { if (trackedWorkItemIds.has(suggestion.id)) continue; + const claimId = await claimSuggestionSend({ + surface: 'teams', + channelId: params.channelId, + ...(params.threadId ? { threadId: params.threadId } : {}), + workItemId: suggestion.id, + createdByUserId: params.createdByUserId, + eventId: params.eventId, + }); + if (!claimId) continue; + const posted = await params.provider.postMessage({ channelId: params.channelId, serviceUrl: params.serviceUrl, @@ -284,14 +345,11 @@ export async function postFastAutomationSuggestionsToTeams(params: { text: formatSuggestion(suggestion), textFormat: 'markdown', }); - await trackSuggestion({ - surface: 'teams', + await finalizeSuggestionSend({ + claimId, channelId: posted.channelId, messageId: posted.messageId, ...(posted.threadId ? { threadId: posted.threadId } : {}), - workItemId: suggestion.id, - createdByUserId: params.createdByUserId, - eventId: params.eventId, }); } } @@ -312,6 +370,16 @@ export async function postFastAutomationSuggestionsToTelegram(params: { for (const suggestion of suggestions) { if (trackedWorkItemIds.has(suggestion.id)) continue; + const claimId = await claimSuggestionSend({ + surface: 'telegram', + channelId: params.channelId, + ...(params.threadId ? { threadId: params.threadId } : {}), + workItemId: suggestion.id, + createdByUserId: params.createdByUserId, + eventId: params.eventId, + }); + if (!claimId) continue; + const posted = await params.provider.postMessage({ channelId: params.channelId, ...(params.threadId ? { threadId: params.threadId } : {}), @@ -319,14 +387,11 @@ export async function postFastAutomationSuggestionsToTelegram(params: { textFormat: 'markdown', buttons: [[{ text: 'Start', callbackData: `idea:${suggestion.id}` }]], }); - await trackSuggestion({ - surface: 'telegram', + await finalizeSuggestionSend({ + claimId, channelId: posted.channelId, messageId: posted.messageId, ...(posted.threadId ? { threadId: posted.threadId } : {}), - workItemId: suggestion.id, - createdByUserId: params.createdByUserId, - eventId: params.eventId, }); } }