From a1c1dbf46587403c0b1b84dad4cc54455c7f5c92 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:58:28 +0000 Subject: [PATCH 1/4] fix: preserve Fast suggestion origin session and Slack thread --- .../__tests__/callback-actions.test.ts | 10 +- .../__tests__/setup-suggestions.test.ts | 5 + .../src/handlers/discord/callback-actions.ts | 1 + .../src/handlers/discord/setup-suggestions.ts | 2 + .../reactions-chat-reply-suggestions.test.ts | 306 ++++++++++-------- .../src/handlers/slack/events/reactions.ts | 13 +- .../current-thread-suggestion-reaction.ts | 4 + .../handlers/tasks/suggestion-launch.test.ts | 20 +- .../src/handlers/tasks/suggestion-launch.ts | 19 +- .../handlers/teams/__tests__/index.test.ts | 6 + .../__tests__/suggestion-start.db.test.ts | 8 + apps/api/src/handlers/teams/index.ts | 4 + .../src/handlers/teams/suggestion-start.ts | 18 +- .../__tests__/callback-actions.test.ts | 20 +- ...laim-telegram-suggestion-launch.db.test.ts | 30 +- .../src/handlers/telegram/callback-actions.ts | 1 + .../handlers/telegram/setup-suggestions.ts | 2 + .../tracked-suggestion-cards.test.ts | 7 + .../db/src/lib/tracked-suggestion-cards.ts | 4 + .../lib/fast-agent-parent-event.test.ts | 65 ++++ .../src/server/lib/fast-agent-parent-event.ts | 31 ++ .../lib/fast-automation-suggestions.test.ts | 27 +- .../server/lib/fast-automation-suggestions.ts | 12 + 23 files changed, 450 insertions(+), 165 deletions(-) diff --git a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts index 6af758bd4..c7c529649 100644 --- a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts @@ -1,4 +1,5 @@ import { ALL_REPOSITORIES, FAST_EXECUTION } from '@roomote/types'; +import * as suggestionLaunch from '../../tasks/suggestion-launch.js'; const mocks = vi.hoisted(() => ({ findRun: vi.fn(), @@ -270,6 +271,9 @@ describe('Discord component callbacks', () => { }); it('starts a coding task for a pinned suggestion', async () => { + const resolveOrigin = vi + .spyOn(suggestionLaunch, 'resolveSuggestionOriginSessionId') + .mockResolvedValueOnce('session-origin'); const claimedAt = new Date('2026-08-28T00:00:00.000Z'); mocks.getSessionForTask.mockResolvedValue({ id: 'session-origin' }); mocks.claimSuggestionByMessage.mockResolvedValue({ @@ -283,6 +287,7 @@ describe('Discord component callbacks', () => { targetEnvironmentId: null, usesRouterLaunch: false, sourceTaskId: 'scan-task-1', + originSessionId: 'session-card', launchClaimedAt: claimedAt, }, }); @@ -321,10 +326,7 @@ describe('Discord component callbacks', () => { }); expect(mocks.startNewTask).toHaveBeenCalled(); - expect(mocks.getSessionForTask).toHaveBeenCalledWith( - expect.anything(), - 'scan-task-1', - ); + expect(resolveOrigin).toHaveBeenCalledWith('scan-task-1', 'session-card'); expect(mocks.launchPinned).toHaveBeenCalledWith( expect.objectContaining({ originSessionId: 'session-origin' }), ); diff --git a/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts b/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts index 5490f3166..7817f6b7c 100644 --- a/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts +++ b/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts @@ -213,6 +213,10 @@ describe('Discord setup suggestions', () => { }); it('requires the button to belong to the current Discord thread', async () => { + findTrackedCardMock.mockResolvedValueOnce({ + id: 'tracked-1', + metadata: { originSessionId: 'session-card' }, + }); const claim = await claimDiscordSuggestionLaunch({ suggestionId: 'suggestion-1', channelId: 'thread-1', @@ -227,6 +231,7 @@ describe('Discord setup suggestions', () => { expect(claim).toMatchObject({ id: 'suggestion-1', targetRepositoryFullName: 'owner/repo', + originSessionId: 'session-card', }); findTrackedCardMock.mockResolvedValueOnce(null); diff --git a/apps/api/src/handlers/discord/callback-actions.ts b/apps/api/src/handlers/discord/callback-actions.ts index a75a9c61b..0f8dda449 100644 --- a/apps/api/src/handlers/discord/callback-actions.ts +++ b/apps/api/src/handlers/discord/callback-actions.ts @@ -435,6 +435,7 @@ async function launchClaimedDiscordSuggestion(input: { }); const originSessionId = await resolveSuggestionOriginSessionId( suggestion.sourceTaskId, + suggestion.originSessionId, ); let launchedRunId: number | null = null; const pinned = await launchPinnedFastSessionTask({ diff --git a/apps/api/src/handlers/discord/setup-suggestions.ts b/apps/api/src/handlers/discord/setup-suggestions.ts index bd960458f..ccb8cc09c 100644 --- a/apps/api/src/handlers/discord/setup-suggestions.ts +++ b/apps/api/src/handlers/discord/setup-suggestions.ts @@ -55,6 +55,7 @@ type DiscordSuggestionLaunchClaim = { usesRouterLaunch: boolean; /** The scan or onboarding task that produced the suggestion. */ sourceTaskId: string | null; + originSessionId?: unknown; launchClaimedAt: Date; }; @@ -229,6 +230,7 @@ export async function claimDiscordSuggestionLaunch(input: { : {}), usesRouterLaunch: routed, sourceTaskId: claimed.sourceTaskId, + originSessionId: trackedCard.metadata?.originSessionId, launchClaimedAt: claimed.launchClaimedAt, }; } diff --git a/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts b/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts index e3d0742cc..743111208 100644 --- a/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts +++ b/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts @@ -178,6 +178,7 @@ describe('chat reply suggestion reactions', () => { mocks.parseSuggestionMetadata.mockReturnValue(null); mocks.getConfiguration.mockResolvedValue(null); workItem.targetRepositoryFullName = 'acme/app'; + workItem.sourceTaskId = 'scan-task-1'; workItem.targetEnvironmentId = 'environment-1'; mocks.routeFastReaction.mockResolvedValue(false); mocks.trackedMessageFindFirst.mockResolvedValue({ @@ -436,157 +437,184 @@ describe('chat reply suggestion reactions', () => { expect(slack.deleteMessage).not.toHaveBeenCalled(); }); - it('binds a router-backed suggestion to the automation Session before starting Fast in its report thread', async () => { - mocks.getSessionForTask.mockResolvedValue({ id: 'session-origin' }); - mocks.sessionsFindFirst.mockResolvedValue({ - fastConversationId: 'fast-origin', - }); - mocks.conversationFindById.mockResolvedValue({ - conversation: { + it.each(['task', 'fast-report'])( + 'binds a router-backed %s suggestion to the automation Session before starting Fast in its report thread', + async (source) => { + if (source === 'fast-report') workItem.sourceTaskId = null; + mocks.getSessionForTask.mockResolvedValue({ id: 'session-origin' }); + mocks.sessionsFindFirst.mockResolvedValue({ + id: 'session-origin', + fastConversationId: 'fast-origin', + }); + mocks.conversationFindById.mockResolvedValue({ + conversation: { + surface: 'slack', + workspaceId: 'T1', + conversationId: 'report-thread-ts', + replyTarget: { channelId: 'C1', threadId: 'report-thread-ts' }, + }, + }); + mocks.trackedMessageFindFirst.mockResolvedValue({ + id: 'tracked-message-1', + workItemId: 'work-item-1', + threadTs: 'report-thread-ts', surface: 'slack', - workspaceId: 'T1', - conversationId: 'report-thread-ts', - replyTarget: { channelId: 'C1', threadId: 'report-thread-ts' }, - }, - }); - mocks.trackedMessageFindFirst.mockResolvedValue({ - id: 'tracked-message-1', - workItemId: 'work-item-1', - threadTs: 'report-thread-ts', - surface: 'slack', - channelId: 'C1', - metadata: { suggestionType: 'suggested_tasks', launchRouting: 'router' }, - }); - mocks.lookupSlackUserMapping.mockResolvedValue({ - hasInactiveMapping: false, - activeMapping: { - userId: 'user-1', - }, - }); - const slack = { - postMessage: vi.fn(async () => 'seeded-thread-ts'), - deleteMessage: vi.fn(async () => undefined), - getMessageMetadata: vi.fn(), - }; + channelId: 'C1', + metadata: { + suggestionType: 'suggested_tasks', + launchRouting: 'router', + ...(source === 'fast-report' + ? { originSessionId: 'session-origin' } + : {}), + }, + }); + mocks.lookupSlackUserMapping.mockResolvedValue({ + hasInactiveMapping: false, + activeMapping: { + userId: 'user-1', + }, + }); + const slack = { + postMessage: vi.fn(async () => 'seeded-thread-ts'), + deleteMessage: vi.fn(async () => undefined), + getMessageMetadata: vi.fn(), + }; - await handleReactionAddedEvent({ - context: { - teamId: 'T1', - slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, - slack, - } as never, - event: { - type: 'reaction_added', - user: 'U1', - reaction: 'thumbsup', - item: { type: 'message', channel: 'C1', ts: 'card-ts' }, - event_ts: 'event-ts', - }, - }); + await handleReactionAddedEvent({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, + slack, + } as never, + event: { + type: 'reaction_added', + user: 'U1', + reaction: 'thumbsup', + item: { type: 'message', channel: 'C1', ts: 'card-ts' }, + event_ts: 'event-ts', + }, + }); - expect(mocks.conversationGetOrCreate).toHaveBeenCalledWith({ - userId: 'user-1', - sessionId: 'session-origin', - conversation: { - surface: 'slack', - workspaceId: 'T1', - conversationId: 'report-thread-ts', - replyTarget: { channelId: 'C1', threadId: 'report-thread-ts' }, - }, - }); - expect( - mocks.conversationGetOrCreate.mock.invocationCallOrder[0], - ).toBeLessThan(mocks.startFastAgentResponse.mock.invocationCallOrder[0]!); - expect(mocks.startFastAgentResponse).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'user-1', - event: expect.objectContaining({ + expect(slack.postMessage).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ channel: 'C1', thread_ts: 'report-thread-ts', - agentContext: 'implementation prompt', }), - }), - ); - expect(mocks.finalizeWorkItemLaunched).toHaveBeenCalledWith( - expect.anything(), - { id: 'work-item-1', taskId: null, claimedAt }, - ); - }); - - it("announces a pinned launch in the origin Session's own thread instead of seeding one", async () => { - mocks.getSessionForTask.mockResolvedValue({ id: 'session-origin' }); - mocks.sessionsFindFirst.mockResolvedValue({ - fastConversationId: 'fast-origin', - }); - mocks.conversationFindById.mockResolvedValue({ - id: 'fast-origin', - conversation: { - surface: 'slack', - workspaceId: 'T1', - conversationId: 'report-thread-ts', - replyTarget: { channelId: 'C_REPORTS', threadId: 'report-thread-ts' }, - }, - }); - mocks.trackedMessageFindFirst.mockResolvedValue({ - id: 'tracked-message-1', - workItemId: 'work-item-1', - metadata: { suggestionType: 'suggested_tasks' }, - }); - mocks.lookupSlackUserMapping.mockResolvedValue({ - hasInactiveMapping: false, - activeMapping: { userId: 'user-1' }, - }); - const postMessage = vi.fn(async () => 'announce-ts'); - const slack = { - postMessage, - deleteMessage: vi.fn(async () => undefined), - getMessageMetadata: vi.fn(), - }; - - await handleReactionAddedEvent({ - context: { - teamId: 'T1', - slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, - slack, - } as never, - event: { - type: 'reaction_added', - user: 'U1', - reaction: 'thumbsup', - item: { type: 'message', channel: 'C1', ts: 'card-ts' }, - event_ts: 'event-ts', - }, - }); + ); + expect(mocks.conversationGetOrCreate).toHaveBeenCalledWith({ + userId: 'user-1', + sessionId: 'session-origin', + conversation: { + surface: 'slack', + workspaceId: 'T1', + conversationId: 'report-thread-ts', + replyTarget: { channelId: 'C1', threadId: 'report-thread-ts' }, + }, + }); + expect( + mocks.conversationGetOrCreate.mock.invocationCallOrder[0], + ).toBeLessThan(mocks.startFastAgentResponse.mock.invocationCallOrder[0]!); + expect(mocks.startFastAgentResponse).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + event: expect.objectContaining({ + channel: 'C1', + thread_ts: 'report-thread-ts', + agentContext: 'implementation prompt', + }), + }), + ); + expect(mocks.finalizeWorkItemLaunched).toHaveBeenCalledWith( + expect.anything(), + { id: 'work-item-1', taskId: null, claimedAt }, + ); + }, + ); - // The announcement is a reply in the automation's report thread. - expect(postMessage).toHaveBeenCalledWith( - expect.objectContaining({ - channel: 'C_REPORTS', - thread_ts: 'report-thread-ts', - }), - ); - expect(mocks.launchPinned).toHaveBeenCalledWith( - expect.objectContaining({ - originSessionId: 'session-origin', + it.each(['task', 'fast-report'])( + "announces a pinned %s launch in the origin Session's own thread instead of seeding one", + async (source) => { + if (source === 'fast-report') workItem.sourceTaskId = null; + mocks.getSessionForTask.mockResolvedValue({ id: 'session-origin' }); + mocks.sessionsFindFirst.mockResolvedValue({ + id: 'session-origin', + fastConversationId: 'fast-origin', + }); + mocks.conversationFindById.mockResolvedValue({ + id: 'fast-origin', conversation: { surface: 'slack', workspaceId: 'T1', conversationId: 'report-thread-ts', - replyTarget: { - channelId: 'C_REPORTS', - threadId: 'report-thread-ts', - }, + replyTarget: { channelId: 'C_REPORTS', threadId: 'report-thread-ts' }, }, - }), - ); - expect(mocks.liveTaskLauncher).toHaveBeenCalledWith( - expect.objectContaining({ - channelId: 'C_REPORTS', - threadTs: 'report-thread-ts', - messageId: 'announce-ts', - }), - ); - }); + }); + mocks.trackedMessageFindFirst.mockResolvedValue({ + id: 'tracked-message-1', + workItemId: 'work-item-1', + metadata: { + suggestionType: 'suggested_tasks', + ...(source === 'fast-report' + ? { originSessionId: 'session-origin' } + : {}), + }, + }); + mocks.lookupSlackUserMapping.mockResolvedValue({ + hasInactiveMapping: false, + activeMapping: { userId: 'user-1' }, + }); + const postMessage = vi.fn(async () => 'announce-ts'); + const slack = { + postMessage, + deleteMessage: vi.fn(async () => undefined), + getMessageMetadata: vi.fn(), + }; + + await handleReactionAddedEvent({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, + slack, + } as never, + event: { + type: 'reaction_added', + user: 'U1', + reaction: 'thumbsup', + item: { type: 'message', channel: 'C1', ts: 'card-ts' }, + event_ts: 'event-ts', + }, + }); + + // The announcement is a reply in the automation's report thread. + expect(postMessage).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + channel: 'C_REPORTS', + thread_ts: 'report-thread-ts', + }), + ); + expect(mocks.launchPinned).toHaveBeenCalledWith( + expect.objectContaining({ + originSessionId: 'session-origin', + conversation: { + surface: 'slack', + workspaceId: 'T1', + conversationId: 'report-thread-ts', + replyTarget: { + channelId: 'C_REPORTS', + threadId: 'report-thread-ts', + }, + }, + }), + ); + expect(mocks.liveTaskLauncher).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'C_REPORTS', + threadTs: 'report-thread-ts', + messageId: 'announce-ts', + }), + ); + }, + ); it('launches through the automation Session bound when its report was published', async () => { mocks.getSessionForTask.mockResolvedValue({ id: 'session-origin' }); diff --git a/apps/api/src/handlers/slack/events/reactions.ts b/apps/api/src/handlers/slack/events/reactions.ts index a50ea5707..f3b8c31ba 100644 --- a/apps/api/src/handlers/slack/events/reactions.ts +++ b/apps/api/src/handlers/slack/events/reactions.ts @@ -560,13 +560,14 @@ async function launchTaskSuggestionTaskFromReaction({ let announceMessageTs: string | undefined; let announceChannelId = channelId; let taskRun: { id: number | null; taskId: string | null } | null = null; - const originSessionId = await resolveSuggestionOriginSessionId( - workItem.sourceTaskId, - ); - const originThread = originSessionId - ? await resolveOriginSessionSlackThread({ originSessionId, teamId }) - : null; try { + const originSessionId = await resolveSuggestionOriginSessionId( + workItem.sourceTaskId, + suggestionCard.metadata?.originSessionId, + ); + const originThread = originSessionId + ? await resolveOriginSessionSlackThread({ originSessionId, teamId }) + : null; announceChannelId = originThread?.channelId ?? channelId; announceMessageTs = await slack.postMessage({ channel: announceChannelId, diff --git a/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts b/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts index 179fd1e70..8c35f90b6 100644 --- a/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts +++ b/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts @@ -18,6 +18,7 @@ export type ClaimedCurrentThreadSuggestion = { launchTarget?: string; /** The scan or onboarding task that produced the suggestion. */ sourceTaskId?: string | null; + originSessionId?: unknown; launchClaimedAt: Date; }; @@ -99,6 +100,9 @@ export async function claimCurrentThreadSuggestionByMessage( ? { launchTarget: trackedCard.metadata.launchTarget } : {}), sourceTaskId: claimed.sourceTaskId, + ...(typeof trackedCard.metadata?.originSessionId === 'string' + ? { originSessionId: trackedCard.metadata.originSessionId } + : {}), launchClaimedAt: claimed.launchClaimedAt, }, }; diff --git a/apps/api/src/handlers/tasks/suggestion-launch.test.ts b/apps/api/src/handlers/tasks/suggestion-launch.test.ts index 24e07f2f7..2de66471a 100644 --- a/apps/api/src/handlers/tasks/suggestion-launch.test.ts +++ b/apps/api/src/handlers/tasks/suggestion-launch.test.ts @@ -3,10 +3,13 @@ const mocks = vi.hoisted(() => ({ release: vi.fn(), cancel: vi.fn(), getSessionForTask: vi.fn(), + findSession: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ - db: {}, + db: { query: { sessions: { findFirst: mocks.findSession } } }, + eq: vi.fn(), + sessions: { id: 'sessions.id' }, finalizeWorkItemLaunched: mocks.finalize, releaseWorkItemClaim: mocks.release, getSessionForTask: mocks.getSessionForTask, @@ -245,6 +248,21 @@ describe('launchClaimedSuggestedTask', () => { }); describe('resolveSuggestionOriginSessionId', () => { + it('uses the persisted canonical origin for a taskless Fast suggestion', async () => { + mocks.findSession.mockResolvedValue({ id: 'session-fast-origin' }); + await expect( + resolveSuggestionOriginSessionId(null, 'session-fast-origin'), + ).resolves.toBe('session-fast-origin'); + expect(mocks.getSessionForTask).not.toHaveBeenCalled(); + }); + + it('does not silently create a new origin when the persisted Session is missing', async () => { + mocks.findSession.mockResolvedValue(null); + await expect( + resolveSuggestionOriginSessionId(null, 'deleted-session'), + ).rejects.toThrow('origin Session is no longer available'); + }); + it('returns the Session that owns the source task', async () => { mocks.getSessionForTask.mockResolvedValue({ id: 'session-origin' }); diff --git a/apps/api/src/handlers/tasks/suggestion-launch.ts b/apps/api/src/handlers/tasks/suggestion-launch.ts index 5ea7c8520..8144a2e63 100644 --- a/apps/api/src/handlers/tasks/suggestion-launch.ts +++ b/apps/api/src/handlers/tasks/suggestion-launch.ts @@ -3,6 +3,8 @@ import { finalizeWorkItemLaunched, releaseWorkItemClaim, getSessionForTask, + eq, + sessions, } from '@roomote/db/server'; import { isDeploymentReadOnlyError } from '@roomote/types'; @@ -181,13 +183,24 @@ export async function launchClaimedSuggestedTask(input: { } /** - * The Session that owns the task which produced a suggestion, so a launch - * lands next to that scan instead of opening a new Session. Null when the - * suggestion has no source task or that task has no Session. + * Fast reports can produce suggestions without a source task. Their tracked + * cards retain the canonical Session; older task-backed cards resolve via the + * task instead. Neither path derives ownership from message timestamps. */ export async function resolveSuggestionOriginSessionId( sourceTaskId: string | null | undefined, + originSessionId?: unknown, ): Promise { + if (typeof originSessionId === 'string' && originSessionId.trim()) { + const session = await db.query.sessions.findFirst({ + where: eq(sessions.id, originSessionId), + columns: { id: true }, + }); + if (!session) { + throw new Error('The suggestion origin Session is no longer available.'); + } + return session.id; + } if (!sourceTaskId) { return null; } diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index 1fb2e044a..48afa30b5 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as suggestionLaunch from '../../tasks/suggestion-launch.js'; const { authAccountsFindFirstMock, @@ -599,6 +600,9 @@ describe('Teams webhook handler', () => { }); it('launches a pinned suggestion through the owning Fast Session without a model turn', async () => { + const resolveOrigin = vi + .spyOn(suggestionLaunch, 'resolveSuggestionOriginSessionId') + .mockResolvedValueOnce('session-origin'); trackedSuggestionMessageFindFirstMock.mockResolvedValue({ workItemId: 'suggestion-1', }); @@ -615,6 +619,7 @@ describe('Teams webhook handler', () => { targetRepositoryFullName: 'acme/app', targetEnvironmentId: null, sourceTaskId: 'scan-task-1', + originSessionId: 'session-card', launchClaimedAt: new Date('2026-08-07T00:00:00.000Z'), }, }); @@ -713,6 +718,7 @@ describe('Teams webhook handler', () => { kickoffMessage: 'Started a task in acme/app.', }), ); + expect(resolveOrigin).toHaveBeenCalledWith('scan-task-1', 'session-card'); expect(enqueueTaskMock).toHaveBeenCalledWith( expect.objectContaining({ initiator: { kind: 'user', userId: 'mapped-user-1' }, diff --git a/apps/api/src/handlers/teams/__tests__/suggestion-start.db.test.ts b/apps/api/src/handlers/teams/__tests__/suggestion-start.db.test.ts index 8787bf9d7..bd87bfda5 100644 --- a/apps/api/src/handlers/teams/__tests__/suggestion-start.db.test.ts +++ b/apps/api/src/handlers/teams/__tests__/suggestion-start.db.test.ts @@ -26,6 +26,7 @@ describe('resolveAndClaimTeamsSuggestionStart (work_items launch CAS)', () => { channelId?: string; threadId?: string; oneMessagePerSuggestion?: boolean; + originSessionId?: string; }): Promise { const channelId = params.channelId ?? conversationId; const rows = await db @@ -58,6 +59,9 @@ describe('resolveAndClaimTeamsSuggestionStart (work_items launch CAS)', () => { metadata: { suggestionType: 'suggested_tasks', suggestionKey: `source-task:${workItemId}`, + ...(params.originSessionId + ? { originSessionId: params.originSessionId } + : {}), ...(params.oneMessagePerSuggestion ? { suggestionGroupKey: 'source-task' } : {}), @@ -182,11 +186,13 @@ describe('resolveAndClaimTeamsSuggestionStart (work_items launch CAS)', () => { it('resolves against the newest suggestion group, not an older post', async () => { await seedSuggestionGroup({ introMessageId: 'intro-old', + originSessionId: 'session-old', titles: ['Old idea one', 'Old idea two'], createdAt: new Date(Date.now() - 60 * 60 * 1000), }); const [newFirstId] = await seedSuggestionGroup({ introMessageId: 'intro-new', + originSessionId: 'session-new', titles: ['New idea one'], createdAt: new Date(), }); @@ -204,6 +210,8 @@ describe('resolveAndClaimTeamsSuggestionStart (work_items launch CAS)', () => { expect(resolution.suggestion.id).toBe(newFirstId); expect(resolution.suggestion.title).toBe('New idea one'); + expect(resolution.suggestion.originSessionId).toBe('session-new'); + expect(resolution.suggestion.sourceTaskId).toBeNull(); // Idea 2 exists only in the old group, so against the newest (1-item) // group it is out of range rather than a stale-list launch. diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index 5c5604535..c2435f8f0 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -1564,6 +1564,7 @@ async function launchPinnedTeamsSuggestionTask(input: { suggestionId: string; /** The task that produced the suggestion; its Session hosts the launch. */ sourceTaskId?: string | null; + originSessionId?: unknown; queuedMessage: QueuedTeamsCommunicationMessage; workspace: TeamsWorkspaceSelection; }) { @@ -1578,6 +1579,7 @@ async function launchPinnedTeamsSuggestionTask(input: { } const originSessionId = await resolveSuggestionOriginSessionId( input.sourceTaskId, + input.originSessionId, ); let launchResult: { id: number; taskId: string } | null = null; const pinned = await launchPinnedFastSessionTask({ @@ -2163,6 +2165,7 @@ teams.post('/', async (c) => { mappedUserId: mappedUserId!, suggestionId: claimedSuggestionReaction.id, sourceTaskId: claimedSuggestionReaction.sourceTaskId, + originSessionId: claimedSuggestionReaction.originSessionId, queuedMessage: { ...queuedMessage!, text: promptText, @@ -2412,6 +2415,7 @@ teams.post('/', async (c) => { mappedUserId, suggestionId: resolution.suggestion.id, sourceTaskId: resolution.suggestion.sourceTaskId, + originSessionId: resolution.suggestion.originSessionId, queuedMessage: { ...queuedMessage!, text: promptText }, workspace: workspaceOverride!, }), diff --git a/apps/api/src/handlers/teams/suggestion-start.ts b/apps/api/src/handlers/teams/suggestion-start.ts index 2cd8cfd10..10ab3e3ea 100644 --- a/apps/api/src/handlers/teams/suggestion-start.ts +++ b/apps/api/src/handlers/teams/suggestion-start.ts @@ -81,6 +81,7 @@ export type ClaimedTeamsSuggestion = { launchTarget?: string; /** The scan or onboarding task that produced the suggestion. */ sourceTaskId?: string | null; + originSessionId?: unknown; launchClaimedAt: Date; }; @@ -122,6 +123,7 @@ export async function resolveAndClaimTeamsSuggestionStart(input: { messageTs: trackedMessages.messageTs, threadTs: trackedMessages.threadTs, createdAt: trackedMessages.createdAt, + metadata: trackedMessages.metadata, }) .from(trackedMessages) .where( @@ -148,7 +150,7 @@ export async function resolveAndClaimTeamsSuggestionStart(input: { // Group cards by their intro message and keep the newest group: the list the // user is replying to. message_ts is ':'; strip // the known workItemId suffix (intro ids may themselves contain ':'). - const groups = new Map(); + const groups = new Map(); for (const card of scopedCards) { if (!card.workItemId || !card.messageTs) { @@ -162,7 +164,7 @@ export async function resolveAndClaimTeamsSuggestionStart(input: { const group = groups.get(groupKey); if (group) { - group.workItemIds.push(card.workItemId); + group.cards.push(card); if (card.createdAt > group.createdAt) { group.createdAt = card.createdAt; @@ -170,7 +172,7 @@ export async function resolveAndClaimTeamsSuggestionStart(input: { } else { groups.set(groupKey, { createdAt: card.createdAt, - workItemIds: [card.workItemId], + cards: [card], }); } } @@ -184,7 +186,12 @@ export async function resolveAndClaimTeamsSuggestionStart(input: { const items = await db .select({ id: workItems.id, title: workItems.title }) .from(workItems) - .where(inArray(workItems.id, latestGroup.workItemIds)) + .where( + inArray( + workItems.id, + latestGroup.cards.map((card) => card.workItemId!), + ), + ) .orderBy(asc(workItems.sortOrder), asc(workItems.createdAt)); const target = items[input.ideaNumber - 1]; @@ -209,6 +216,9 @@ export async function resolveAndClaimTeamsSuggestionStart(input: { targetRepositoryFullName: claimed.targetRepositoryFullName, targetEnvironmentId: claimed.targetEnvironmentId, sourceTaskId: claimed.sourceTaskId, + originSessionId: latestGroup.cards.find( + (card) => card.workItemId === target.id, + )?.metadata?.originSessionId, launchClaimedAt: claimed.launchClaimedAt, }, }; diff --git a/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts b/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts index 2c05448eb..1e65b18e5 100644 --- a/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TelegramCallbackQuery } from '@roomote/communication/telegram-update'; +import * as suggestionLaunch from '../../tasks/suggestion-launch.js'; const { answerCallbackMock, @@ -269,12 +270,23 @@ describe('handleTelegramCallbackQuery suggestion launch lifecycle', () => { }); it('starts a suggestion in a fresh topic while preserving its source topic for fallback', async () => { + const resolveOrigin = vi + .spyOn(suggestionLaunch, 'resolveSuggestionOriginSessionId') + .mockResolvedValueOnce('session-origin'); + claimTelegramSuggestionLaunchMock.mockResolvedValueOnce({ + id: WORK_ITEM_ID, + title: 'Fix the flaky test', + brief: 'The retry loop never terminates.', + investigationContext: null, + targetRepositoryFullName: '__all_repositories__', + launchTarget: '__all_repositories__', + sourceTaskId: null, + originSessionId: 'session-card', + launchClaimedAt: CLAIMED_AT, + }); await handleTelegramCallbackQuery(buildSuggestionQuery(44)); - expect(getSessionForTaskMock).toHaveBeenCalledWith( - expect.anything(), - 'scan-task-1', - ); + expect(resolveOrigin).toHaveBeenCalledWith(null, 'session-card'); expect(launchPinnedMock).toHaveBeenCalledWith( expect.objectContaining({ originSessionId: 'session-origin' }), ); diff --git a/apps/api/src/handlers/telegram/__tests__/claim-telegram-suggestion-launch.db.test.ts b/apps/api/src/handlers/telegram/__tests__/claim-telegram-suggestion-launch.db.test.ts index 9648b28fd..183cb7a8a 100644 --- a/apps/api/src/handlers/telegram/__tests__/claim-telegram-suggestion-launch.db.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/claim-telegram-suggestion-launch.db.test.ts @@ -16,6 +16,7 @@ import { } from '@roomote/db/server'; import { claimTelegramSuggestionLaunch } from '../setup-suggestions'; +import { claimCurrentThreadSuggestionByMessage } from '../../tasks/current-thread-suggestion-reaction'; describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { const workItemIds: string[] = []; @@ -27,6 +28,7 @@ describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { channelId?: string; launchRouting?: 'router'; launchTarget?: string; + originSessionId?: string; }): Promise { const [row] = await db .insert(workItems) @@ -55,6 +57,9 @@ describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { metadata: { suggestionType: 'setup_onboarding', suggestionKey: `source-task:${workItemId}`, + ...(overrides?.originSessionId + ? { originSessionId: overrides.originSessionId } + : {}), ...(overrides?.launchRouting ? { launchRouting: overrides.launchRouting } : {}), @@ -78,7 +83,9 @@ describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { }); it('claims an open work item exactly once and flips it to launching', async () => { - const workItemId = await seedSuggestionWorkItem(); + const workItemId = await seedSuggestionWorkItem({ + originSessionId: 'session-card', + }); const claimed = await claimTelegramSuggestionLaunch({ suggestionId: workItemId, @@ -88,6 +95,8 @@ describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { expect(claimed).not.toBeNull(); expect(claimed?.id).toBe(workItemId); expect(claimed?.title).toBe('Fix the flaky test'); + expect(claimed?.originSessionId).toBe('session-card'); + expect(claimed?.sourceTaskId).toBeNull(); const [row] = await db .select({ @@ -107,6 +116,25 @@ describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { ); }); + it('retains the taskless origin through the shared message-reaction claim', async () => { + const workItemId = await seedSuggestionWorkItem({ + originSessionId: 'session-card', + }); + const claimed = await claimCurrentThreadSuggestionByMessage({ + surface: 'telegram', + channelId: chatId, + messageId: `msg:${workItemId}`, + }); + expect(claimed).toMatchObject({ + outcome: 'claimed', + suggestion: { + id: workItemId, + sourceTaskId: null, + originSessionId: 'session-card', + }, + }); + }); + it('returns null for a second claim (double-tap is a no-op)', async () => { const workItemId = await seedSuggestionWorkItem(); diff --git a/apps/api/src/handlers/telegram/callback-actions.ts b/apps/api/src/handlers/telegram/callback-actions.ts index 58be6e3bd..3cebdb265 100644 --- a/apps/api/src/handlers/telegram/callback-actions.ts +++ b/apps/api/src/handlers/telegram/callback-actions.ts @@ -388,6 +388,7 @@ async function handleSuggestionLaunchCallback(params: { }); const originSessionId = await resolveSuggestionOriginSessionId( suggestion.sourceTaskId, + suggestion.originSessionId, ); let launchedRunId: number | null = null; const pinned = await launchPinnedFastSessionTask({ diff --git a/apps/api/src/handlers/telegram/setup-suggestions.ts b/apps/api/src/handlers/telegram/setup-suggestions.ts index 9f4060503..bfcf0cefc 100644 --- a/apps/api/src/handlers/telegram/setup-suggestions.ts +++ b/apps/api/src/handlers/telegram/setup-suggestions.ts @@ -168,6 +168,7 @@ export async function claimTelegramSuggestionLaunch(input: { usesRouterLaunch: boolean; /** The scan or onboarding task that produced the suggestion. */ sourceTaskId: string | null; + originSessionId?: unknown; launchClaimedAt: Date; } | null> { // Scope: a suggestion card for this work item must have been posted in this @@ -210,6 +211,7 @@ export async function claimTelegramSuggestionLaunch(input: { : {}), usesRouterLaunch: routed, sourceTaskId: claimed.sourceTaskId, + originSessionId: trackedCard.metadata?.originSessionId, launchClaimedAt: claimed.launchClaimedAt, }; } diff --git a/packages/db/src/lib/__tests__/tracked-suggestion-cards.test.ts b/packages/db/src/lib/__tests__/tracked-suggestion-cards.test.ts index 73a7adad8..741b7c293 100644 --- a/packages/db/src/lib/__tests__/tracked-suggestion-cards.test.ts +++ b/packages/db/src/lib/__tests__/tracked-suggestion-cards.test.ts @@ -10,6 +10,7 @@ import { describe('tracked suggestion cards', () => { it('registers shared card metadata and isolates lookups by surface', async () => { + const originSessionId = '22222222-2222-4222-8222-222222222222'; const user = await userFactory.create(); const [workItem] = await db .insert(workItems) @@ -24,6 +25,7 @@ describe('tracked suggestion cards', () => { await registerTrackedSuggestionCards([ { surface: 'slack', + originSessionId, channelId: 'C123', messageTs: '200.001', threadTs: '100.001', @@ -38,6 +40,7 @@ describe('tracked suggestion cards', () => { await registerTrackedSuggestionCards([ { surface: 'slack', + originSessionId, channelId: 'C123', messageTs: '200.001', threadTs: '100.001', @@ -77,12 +80,16 @@ describe('tracked suggestion cards', () => { threadTs: '100.001', createdByUserId: user.id, metadata: { + originSessionId, suggestionType: 'suggested_tasks', suggestionKey: `event-1:${workItem!.id}`, suggestionGroupKey: 'event-1', launchRouting: 'router', }, }); + expect( + trackedRows.find((row) => row.surface === 'discord')?.metadata, + ).not.toHaveProperty('originSessionId'); expect( await findTrackedSuggestionWorkItemIds({ surface: 'slack', diff --git a/packages/db/src/lib/tracked-suggestion-cards.ts b/packages/db/src/lib/tracked-suggestion-cards.ts index 4acb3f3a1..9cec0fea3 100644 --- a/packages/db/src/lib/tracked-suggestion-cards.ts +++ b/packages/db/src/lib/tracked-suggestion-cards.ts @@ -14,6 +14,7 @@ type SuggestionCardRegistration = { suggestionType: string; suggestionKey: string; suggestionGroupKey?: string; + originSessionId?: string; launchRouting?: 'router'; launchTarget?: string; }; @@ -39,6 +40,9 @@ export async function registerTrackedSuggestionCards( metadata: { suggestionType: registration.suggestionType, suggestionKey: registration.suggestionKey, + ...(registration.originSessionId + ? { originSessionId: registration.originSessionId } + : {}), ...(registration.suggestionGroupKey ? { suggestionGroupKey: registration.suggestionGroupKey } : {}), 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 ad1e2b599..ef47f145f 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 @@ -257,6 +257,8 @@ const parent = { }, }; +const originSessionId = '22222222-2222-4222-8222-222222222222'; + const event = { type: 'artifact_published' as const, taskId: 'task-1', @@ -274,6 +276,7 @@ const event = { describe('deliverFastAgentParentEvent', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findWakeupSession.mockResolvedValue({ id: originSessionId }); mocks.releaseTurnLock.signal = new AbortController().signal; mocks.acquireTurnLock.mockResolvedValue(mocks.releaseTurnLock); mocks.acquireRootBindingLock.mockResolvedValue( @@ -913,6 +916,59 @@ describe('deliverFastAgentParentEvent', () => { }); }); + it.each([true, false])( + 'requires the canonical Session for Slack suggestions (found: %s)', + async (found) => { + const fastConversationId = '33333333-3333-4333-8333-333333333333'; + mocks.findSession.mockResolvedValueOnce({ + id: fastConversationId, + userId: 'u1', + conversation: parent.conversation, + messages: [], + }); + mocks.findWakeupSession.mockResolvedValueOnce( + found ? { id: originSessionId } : null, + ); + mocks.answerQuestion.mockImplementationOnce(async ({ adapter }) => + adapter.postReply({ + purpose: 'closeout', + message: 'Report', + suggestions: [ + { title: 'Investigate', brief: 'Trace the regression.' }, + ], + }), + ); + const delivery = deliverFastAgentParentEvent({ + parent, + event: { + type: 'automation_triggered', + eventId: 'occurrence-canonical', + automationId: 'automation-1', + automationName: 'Weekly scan', + prompt: 'Find regressions.', + trigger: 'schedule', + rootMessageId: '100.001', + }, + }); + if (found) { + await delivery; + expect(mocks.postSlackSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ originSessionId }), + ); + } else { + await expect(delivery).rejects.toThrow( + 'Fast automation origin Session was not found.', + ); + expect(mocks.postSlackSuggestions).not.toHaveBeenCalled(); + } + expect(mocks.findWakeupSession).toHaveBeenCalledWith( + expect.any(Object), + fastConversationId, + ); + expect(mocks.bindConversation).not.toHaveBeenCalled(); + }, + ); + it('posts structured suggestions beneath a Fast Slack automation report', async () => { const suggestions = [ { @@ -952,6 +1008,7 @@ describe('deliverFastAgentParentEvent', () => { true, ); expect(mocks.postSlackSuggestions).toHaveBeenCalledWith({ + originSessionId, slack: expect.any(Object), channelId: 'C123', threadTs: '100.001', @@ -1008,6 +1065,7 @@ describe('deliverFastAgentParentEvent', () => { }), ); expect(mocks.postDiscordSuggestions).toHaveBeenCalledWith({ + originSessionId, provider: expect.any(Object), channelId: 'channel-1', threadId: 'thread-1', @@ -1198,6 +1256,7 @@ describe('deliverFastAgentParentEvent', () => { true, ); expect(mocks.postSlackSuggestions).toHaveBeenCalledWith({ + originSessionId, slack: expect.any(Object), channelId: 'C123', threadTs: '101.001', @@ -1252,6 +1311,7 @@ describe('deliverFastAgentParentEvent', () => { true, ); expect(mocks.postDiscordSuggestions).toHaveBeenCalledWith({ + originSessionId, provider: expect.any(Object), channelId: 'channel-1', threadId: 'thread-1', @@ -1765,12 +1825,17 @@ describe('deliverFastAgentParentEvent', () => { expect(postSuggestions).toHaveBeenCalledWith( expect.objectContaining({ + originSessionId, channelId, eventId: `${surface}-occurrence-1`, createdByUserId: 'u1', suggestions, }), ); + expect(mocks.findWakeupSession).toHaveBeenCalledWith( + expect.any(Object), + parent.sessionId, + ); expect(mocks.recordProviderMessage).toHaveBeenCalledWith({ sessionId: parent.sessionId, conversation: expect.objectContaining({ surface }), 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 331aa1339..a258b1f04 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -420,6 +420,16 @@ function buildFastAutomationSuggestionEventId( : `${event.customAutomationId}:task:${event.taskId}`; } +async function requireFastAutomationOriginSessionId( + fastConversationId: string, +): Promise { + const session = await getSessionForFastConversation(db, fastConversationId); + if (!session) { + throw new Error('Fast automation origin Session was not found.'); + } + return session.id; +} + function buildPrReviewActionNonce(event: FastAgentParentEvent): string { return buildSlackClientMessageId( `${buildEventClientMessageSeed(event)}:pr-review-action`, @@ -919,6 +929,9 @@ async function createSlackFastAgentParentTurn( suggestions.length > 0 ) { await postFastAutomationSuggestionsToSlack({ + originSessionId: await requireFastAutomationOriginSessionId( + session.id, + ), slack, channelId: conversation.replyTarget.channelId, threadTs: messageTs, @@ -961,6 +974,9 @@ async function createSlackFastAgentParentTurn( suggestions.length > 0 ) { await postFastAutomationSuggestionsToSlack({ + originSessionId: await requireFastAutomationOriginSessionId( + session.id, + ), slack, channelId: conversation.replyTarget.channelId, threadTs: rootMessageId, @@ -1340,6 +1356,9 @@ async function createDiscordFastAgentParentTurn( }); if (suggestions.length > 0) { await postFastAutomationSuggestionsToDiscord({ + originSessionId: await requireFastAutomationOriginSessionId( + session.id, + ), provider, channelId: conversation.replyTarget.channelId, ...(conversation.replyTarget.threadId @@ -1439,6 +1458,9 @@ async function createDiscordFastAgentParentTurn( }); if (settleReport && suggestions.length > 0) { await postFastAutomationSuggestionsToDiscord({ + originSessionId: await requireFastAutomationOriginSessionId( + session.id, + ), provider, channelId: conversation.replyTarget.channelId, ...(conversation.replyTarget.threadId @@ -1577,6 +1599,9 @@ async function createTeamsFastAgentParentTurn( }); if (suggestions.length > 0) { await postFastAutomationSuggestionsToTeams({ + originSessionId: await requireFastAutomationOriginSessionId( + session.id, + ), provider, channelId: conversation.replyTarget.channelId, serviceUrl, @@ -1610,6 +1635,9 @@ async function createTeamsFastAgentParentTurn( suggestions.length > 0 ) { await postFastAutomationSuggestionsToTeams({ + originSessionId: await requireFastAutomationOriginSessionId( + session.id, + ), provider, channelId: conversation.replyTarget.channelId, serviceUrl, @@ -1713,6 +1741,9 @@ async function createTelegramFastAgentParentTurn( suggestions.length > 0 ) { await postFastAutomationSuggestionsToTelegram({ + originSessionId: await requireFastAutomationOriginSessionId( + session.id, + ), provider, channelId: conversation.replyTarget.channelId, ...(conversation.replyTarget.threadId 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 3f3a18766..f8bf18f40 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts @@ -18,6 +18,7 @@ import { } from './fast-automation-suggestions'; describe('Fast automation suggestions', () => { + const originSessionId = '22222222-2222-4222-8222-222222222222'; it('persists and tracks reaction-launchable Slack suggestion cards idempotently', async () => { const user = await userFactory.create(); const postMessage = vi.fn().mockResolvedValue('200.001'); @@ -31,6 +32,7 @@ describe('Fast automation suggestions', () => { channelId: 'C123', threadTs: '100.001', eventId: 'automation-1:2026-08-25T00:00:00.000Z', + originSessionId, createdByUserId: user.id, suggestions, }; @@ -78,6 +80,7 @@ describe('Fast automation suggestions', () => { createdByUserId: user.id, metadata: expect.objectContaining({ suggestionType: 'suggested_tasks', + originSessionId, launchRouting: 'router', }), }); @@ -97,6 +100,7 @@ describe('Fast automation suggestions', () => { channelId: 'C-targets', threadTs: 'targets-root', eventId: 'automation-targets', + originSessionId, createdByUserId: user.id, suggestions: [ { @@ -173,6 +177,7 @@ describe('Fast automation suggestions', () => { channelId: 'C-invalid', threadTs: 'invalid-root', eventId: 'automation-invalid-target', + originSessionId, createdByUserId: user.id, suggestions: [ { @@ -201,6 +206,7 @@ describe('Fast automation suggestions', () => { channelId: 'C-user-scoped', threadTs: 'user-scoped-root', eventId: 'automation-user-scoped-target', + originSessionId, createdByUserId: user.id, suggestions: [ { @@ -228,6 +234,7 @@ describe('Fast automation suggestions', () => { channelId: 'channel-1', threadId: 'thread-1', eventId: 'automation-2:2026-08-25T00:00:00.000Z', + originSessionId, createdByUserId: user.id, suggestions: [ { @@ -261,6 +268,7 @@ describe('Fast automation suggestions', () => { metadata: expect.objectContaining({ suggestionType: 'suggested_tasks', launchRouting: 'router', + originSessionId, }), }); }); @@ -280,6 +288,7 @@ describe('Fast automation suggestions', () => { serviceUrl: 'https://smba.example.com/amer/', threadId: 'thread-1', eventId: 'automation-teams', + originSessionId, createdByUserId: user.id, suggestions: [ { title: 'Verify Teams retries', brief: 'Exercise the failure path.' }, @@ -302,7 +311,10 @@ describe('Fast automation suggestions', () => { messageTs: 'message-1', threadTs: 'thread-1', createdByUserId: user.id, - metadata: expect.objectContaining({ launchRouting: 'router' }), + metadata: expect.objectContaining({ + launchRouting: 'router', + originSessionId, + }), }); }); @@ -318,6 +330,7 @@ describe('Fast automation suggestions', () => { provider: { postMessage }, channelId: 'chat-1', eventId: 'automation-telegram', + originSessionId, createdByUserId: user.id, suggestions: [ { @@ -348,7 +361,10 @@ describe('Fast automation suggestions', () => { channelId: 'chat-1', messageTs: 'message-1', createdByUserId: user.id, - metadata: expect.objectContaining({ launchRouting: 'router' }), + metadata: expect.objectContaining({ + launchRouting: 'router', + originSessionId, + }), }); }); @@ -387,6 +403,7 @@ describe('Fast automation suggestions', () => { provider: { postMessage }, channelId: 'conversation-retry', eventId: `automation-retry-${providerResult.provider}`, + originSessionId, createdByUserId: user.id, suggestions: [ { @@ -416,7 +433,10 @@ describe('Fast automation suggestions', () => { channelId: 'conversation-retry', messageTs: null, createdByUserId: user.id, - metadata: expect.objectContaining({ launchRouting: 'router' }), + metadata: expect.objectContaining({ + launchRouting: 'router', + originSessionId, + }), }); }, ); @@ -429,6 +449,7 @@ describe('Fast automation suggestions', () => { channelId: 'C456', threadTs: '300.001', eventId: 'automation-3:2026-08-25T00:00:00.000Z', + originSessionId, createdByUserId: user.id, suggestions: [ { diff --git a/packages/sdk/src/server/lib/fast-automation-suggestions.ts b/packages/sdk/src/server/lib/fast-automation-suggestions.ts index 1c6b8d4aa..c846e1962 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.ts @@ -209,6 +209,7 @@ function formatSuggestion( } async function trackSuggestion(params: { + originSessionId: string; surface: 'slack' | 'discord' | 'teams' | 'telegram'; channelId: string; messageId: string; @@ -229,6 +230,7 @@ async function trackSuggestion(params: { suggestionType: 'suggested_tasks', suggestionKey: `${params.eventId}:${params.workItemId}`, suggestionGroupKey: params.eventId, + originSessionId: params.originSessionId, ...(params.launchTarget ? { launchTarget: params.launchTarget } : { launchRouting: 'router' as const }), @@ -237,6 +239,7 @@ async function trackSuggestion(params: { } async function claimSuggestionSend(params: { + originSessionId: string; surface: 'teams' | 'telegram'; channelId: string; threadId?: string; @@ -259,6 +262,7 @@ async function claimSuggestionSend(params: { suggestionType: 'suggested_tasks', suggestionKey: `${params.eventId}:${params.workItemId}`, suggestionGroupKey: params.eventId, + originSessionId: params.originSessionId, ...(params.launchTarget ? { launchTarget: params.launchTarget } : { launchRouting: 'router' }), @@ -290,6 +294,7 @@ async function finalizeSuggestionSend(params: { } export async function postFastAutomationSuggestionsToSlack(params: { + originSessionId: string; slack: Pick; channelId: string; threadTs: string; @@ -324,6 +329,7 @@ export async function postFastAutomationSuggestionsToSlack(params: { } await trackSuggestion({ surface: 'slack', + originSessionId: params.originSessionId, channelId: params.channelId, messageId, threadId: params.threadTs, @@ -338,6 +344,7 @@ export async function postFastAutomationSuggestionsToSlack(params: { } export async function postFastAutomationSuggestionsToDiscord(params: { + originSessionId: string; provider: Pick; channelId: string; threadId?: string; @@ -364,6 +371,7 @@ export async function postFastAutomationSuggestionsToDiscord(params: { } await trackSuggestion({ surface: 'discord', + originSessionId: params.originSessionId, channelId: posted.threadId ?? posted.channelId, messageId: posted.messageId, ...(posted.threadId ? { threadId: posted.threadId } : {}), @@ -378,6 +386,7 @@ export async function postFastAutomationSuggestionsToDiscord(params: { } export async function postFastAutomationSuggestionsToTeams(params: { + originSessionId: string; provider: Pick; channelId: string; serviceUrl: string; @@ -396,6 +405,7 @@ export async function postFastAutomationSuggestionsToTeams(params: { const claimId = await claimSuggestionSend({ surface: 'teams', + originSessionId: params.originSessionId, channelId: params.channelId, ...(params.threadId ? { threadId: params.threadId } : {}), workItemId: suggestion.id, @@ -426,6 +436,7 @@ export async function postFastAutomationSuggestionsToTeams(params: { } export async function postFastAutomationSuggestionsToTelegram(params: { + originSessionId: string; provider: Pick; channelId: string; threadId?: string; @@ -443,6 +454,7 @@ export async function postFastAutomationSuggestionsToTelegram(params: { const claimId = await claimSuggestionSend({ surface: 'telegram', + originSessionId: params.originSessionId, channelId: params.channelId, ...(params.threadId ? { threadId: params.threadId } : {}), workItemId: suggestion.id, From e77a0a9c1c7ad38a66acf8834b5511338acf09ff Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:09:47 +0000 Subject: [PATCH 2/4] fix: share canonical suggestion origin lookup and null taskless metadata --- packages/sdk/src/server/index.ts | 1 + .../lib/fast-agent-parent-event.test.ts | 2 +- .../src/server/lib/fast-agent-parent-event.ts | 25 +++++--------- .../lib/fast-automation-suggestions.test.ts | 5 ++- .../server/lib/fast-automation-suggestions.ts | 3 +- .../src/server/lib/fast-suggestion-origin.ts | 11 +++++++ .../suggestion-message-metadata.test.ts | 33 ++++++++++--------- .../slack/src/suggestion-message-metadata.ts | 2 +- 8 files changed, 46 insertions(+), 36 deletions(-) create mode 100644 packages/sdk/src/server/lib/fast-suggestion-origin.ts diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index c5faf9981..a0ea4eec6 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -209,6 +209,7 @@ export { createTelegramCommunicationProviderFromRuntimeCredentials } from './lib export { syncTaskCommunicationThreadTitleBestEffort } from './lib/task-thread-title-sync'; export { syncFastAgentSlackTitleBestEffort } from './lib/fast-agent-slack-title-sync'; +export { requireFastSuggestionOriginSessionId } from './lib/fast-suggestion-origin'; export { buildFastAgentParentEventKey, 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 ef47f145f..b6887db99 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 @@ -957,7 +957,7 @@ describe('deliverFastAgentParentEvent', () => { ); } else { await expect(delivery).rejects.toThrow( - 'Fast automation origin Session was not found.', + 'Fast suggestion origin Session was not found.', ); expect(mocks.postSlackSuggestions).not.toHaveBeenCalled(); } 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 a258b1f04..76d92025c 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -88,6 +88,7 @@ import { postFastAutomationSuggestionsToTeams, postFastAutomationSuggestionsToTelegram, } from './fast-automation-suggestions'; +import { requireFastSuggestionOriginSessionId } from './fast-suggestion-origin'; import { buildSignedArtifactRawUrl, @@ -420,16 +421,6 @@ function buildFastAutomationSuggestionEventId( : `${event.customAutomationId}:task:${event.taskId}`; } -async function requireFastAutomationOriginSessionId( - fastConversationId: string, -): Promise { - const session = await getSessionForFastConversation(db, fastConversationId); - if (!session) { - throw new Error('Fast automation origin Session was not found.'); - } - return session.id; -} - function buildPrReviewActionNonce(event: FastAgentParentEvent): string { return buildSlackClientMessageId( `${buildEventClientMessageSeed(event)}:pr-review-action`, @@ -929,7 +920,7 @@ async function createSlackFastAgentParentTurn( suggestions.length > 0 ) { await postFastAutomationSuggestionsToSlack({ - originSessionId: await requireFastAutomationOriginSessionId( + originSessionId: await requireFastSuggestionOriginSessionId( session.id, ), slack, @@ -974,7 +965,7 @@ async function createSlackFastAgentParentTurn( suggestions.length > 0 ) { await postFastAutomationSuggestionsToSlack({ - originSessionId: await requireFastAutomationOriginSessionId( + originSessionId: await requireFastSuggestionOriginSessionId( session.id, ), slack, @@ -1356,7 +1347,7 @@ async function createDiscordFastAgentParentTurn( }); if (suggestions.length > 0) { await postFastAutomationSuggestionsToDiscord({ - originSessionId: await requireFastAutomationOriginSessionId( + originSessionId: await requireFastSuggestionOriginSessionId( session.id, ), provider, @@ -1458,7 +1449,7 @@ async function createDiscordFastAgentParentTurn( }); if (settleReport && suggestions.length > 0) { await postFastAutomationSuggestionsToDiscord({ - originSessionId: await requireFastAutomationOriginSessionId( + originSessionId: await requireFastSuggestionOriginSessionId( session.id, ), provider, @@ -1599,7 +1590,7 @@ async function createTeamsFastAgentParentTurn( }); if (suggestions.length > 0) { await postFastAutomationSuggestionsToTeams({ - originSessionId: await requireFastAutomationOriginSessionId( + originSessionId: await requireFastSuggestionOriginSessionId( session.id, ), provider, @@ -1635,7 +1626,7 @@ async function createTeamsFastAgentParentTurn( suggestions.length > 0 ) { await postFastAutomationSuggestionsToTeams({ - originSessionId: await requireFastAutomationOriginSessionId( + originSessionId: await requireFastSuggestionOriginSessionId( session.id, ), provider, @@ -1741,7 +1732,7 @@ async function createTelegramFastAgentParentTurn( suggestions.length > 0 ) { await postFastAutomationSuggestionsToTelegram({ - originSessionId: await requireFastAutomationOriginSessionId( + originSessionId: await requireFastSuggestionOriginSessionId( session.id, ), provider, 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 f8bf18f40..267198683 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts @@ -47,7 +47,10 @@ describe('Fast automation suggestions', () => { thread_ts: '100.001', client_msg_id: expect.any(String), metadata: expect.objectContaining({ - event_payload: expect.objectContaining({ schemaVersion: 1 }), + event_payload: expect.objectContaining({ + schemaVersion: 1, + sourceTaskId: null, + }), }), }), ); diff --git a/packages/sdk/src/server/lib/fast-automation-suggestions.ts b/packages/sdk/src/server/lib/fast-automation-suggestions.ts index c846e1962..6a36dd70b 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.ts @@ -18,6 +18,7 @@ import { workItems, } from '@roomote/db/server'; import { ALL_REPOSITORIES, FAST_EXECUTION } from '@roomote/types'; +export { requireFastSuggestionOriginSessionId } from './fast-suggestion-origin'; import { buildTaskSuggestionMessageMetadata, type SlackNotifier, @@ -320,7 +321,7 @@ export async function postFastAutomationSuggestionsToSlack(params: { text, blocks: [{ type: 'markdown', text }], metadata: buildTaskSuggestionMessageMetadata({ - sourceTaskId: params.eventId, + sourceTaskId: null, suggestionId: suggestion.id, }), }); diff --git a/packages/sdk/src/server/lib/fast-suggestion-origin.ts b/packages/sdk/src/server/lib/fast-suggestion-origin.ts new file mode 100644 index 000000000..926268256 --- /dev/null +++ b/packages/sdk/src/server/lib/fast-suggestion-origin.ts @@ -0,0 +1,11 @@ +import { db, getSessionForFastConversation } from '@roomote/db/server'; + +export async function requireFastSuggestionOriginSessionId( + fastConversationId: string, +): Promise { + const session = await getSessionForFastConversation(db, fastConversationId); + if (!session) { + throw new Error('Fast suggestion origin Session was not found.'); + } + return session.id; +} diff --git a/packages/slack/src/__tests__/suggestion-message-metadata.test.ts b/packages/slack/src/__tests__/suggestion-message-metadata.test.ts index 43ac5e3fa..fa08aae23 100644 --- a/packages/slack/src/__tests__/suggestion-message-metadata.test.ts +++ b/packages/slack/src/__tests__/suggestion-message-metadata.test.ts @@ -4,19 +4,22 @@ import { } from '../suggestion-message-metadata'; describe('task suggestion message metadata', () => { - it('builds the reaction fallback payload', () => { - expect( - buildTaskSuggestionMessageMetadata({ - sourceTaskId: 'task-1', - suggestionId: 'suggestion-1', - }), - ).toEqual({ - event_type: TASK_SUGGESTION_MESSAGE_METADATA_EVENT_TYPE, - event_payload: { - sourceTaskId: 'task-1', - suggestionId: 'suggestion-1', - schemaVersion: 1, - }, - }); - }); + it.each(['task-1', null])( + 'builds the reaction fallback payload with source task %s', + (sourceTaskId) => { + expect( + buildTaskSuggestionMessageMetadata({ + sourceTaskId, + suggestionId: 'suggestion-1', + }), + ).toEqual({ + event_type: TASK_SUGGESTION_MESSAGE_METADATA_EVENT_TYPE, + event_payload: { + sourceTaskId, + suggestionId: 'suggestion-1', + schemaVersion: 1, + }, + }); + }, + ); }); diff --git a/packages/slack/src/suggestion-message-metadata.ts b/packages/slack/src/suggestion-message-metadata.ts index 220558b35..297f5bc87 100644 --- a/packages/slack/src/suggestion-message-metadata.ts +++ b/packages/slack/src/suggestion-message-metadata.ts @@ -3,7 +3,7 @@ import { TASK_SUGGESTION_MESSAGE_METADATA_EVENT_TYPE } from '@roomote/types'; export { TASK_SUGGESTION_MESSAGE_METADATA_EVENT_TYPE }; export function buildTaskSuggestionMessageMetadata(params: { - sourceTaskId: string; + sourceTaskId: string | null; suggestionId: string; }) { return { From f0d700018d436a730987e297ec476e0d3ad9505c Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:10:53 +0000 Subject: [PATCH 3/4] fix: route SDK origin export through suggestion module --- packages/sdk/src/server/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index a0ea4eec6..53bf3515e 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -209,7 +209,7 @@ export { createTelegramCommunicationProviderFromRuntimeCredentials } from './lib export { syncTaskCommunicationThreadTitleBestEffort } from './lib/task-thread-title-sync'; export { syncFastAgentSlackTitleBestEffort } from './lib/fast-agent-slack-title-sync'; -export { requireFastSuggestionOriginSessionId } from './lib/fast-suggestion-origin'; +export { requireFastSuggestionOriginSessionId } from './lib/fast-automation-suggestions'; export { buildFastAgentParentEventKey, From 21469cc05879cb5dfd21dc27f351b5f055d3a6eb Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:25:11 +0000 Subject: [PATCH 4/4] fix: retain origin conversation for router-backed chat suggestions --- .../__tests__/callback-actions.test.ts | 60 +++++++ .../discord/__tests__/fast-agent.test.ts | 156 +++++++++++++++++- .../src/handlers/discord/callback-actions.ts | 9 +- apps/api/src/handlers/discord/fast-agent.ts | 52 +++++- .../handlers/tasks/suggestion-launch.test.ts | 86 ++++++++++ .../src/handlers/tasks/suggestion-launch.ts | 42 ++++- .../handlers/teams/__tests__/index.test.ts | 146 ++++++++++++++++ apps/api/src/handlers/teams/index.ts | 27 ++- .../__tests__/callback-actions.test.ts | 125 +++++++++++++- .../src/handlers/telegram/callback-actions.ts | 15 +- 10 files changed, 690 insertions(+), 28 deletions(-) diff --git a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts index c7c529649..53e44604a 100644 --- a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts @@ -270,6 +270,66 @@ describe('Discord component callbacks', () => { }); }); + it('preserves a taskless router card origin through the Fast callback and claim settlement', async () => { + const resolveOrigin = vi + .spyOn(suggestionLaunch, 'resolveSuggestionOriginSessionId') + .mockResolvedValueOnce('session-origin'); + const claimedAt = new Date('2026-08-28T00:00:00.000Z'); + mocks.claimSuggestion.mockResolvedValue({ + id: 'suggestion-1', + title: 'Investigate errors', + brief: null, + sourceTaskId: null, + originSessionId: 'session-origin', + usesRouterLaunch: true, + launchClaimedAt: claimedAt, + }); + mocks.resolveChannel.mockResolvedValue({ channelId: 'channel-1' }); + mocks.finalizeWorkItem.mockResolvedValue({ id: 'suggestion-1' }); + + await handleDiscordComponentInteraction({ + provider: { postMessage: vi.fn() } as never, + applicationId: 'app-1', + interactionDeferred: true, + interaction: { + id: 'new-interaction', + application_id: 'app-1', + type: 3, + token: 'token', + channel_id: 'card-thread', + user: { id: 'clicker', username: 'matt' }, + data: { custom_id: 'idea:suggestion-1', component_type: 2 }, + }, + channel: { + channelId: 'card-thread', + parentChannelId: 'channel-1', + channelName: 'Suggestions', + channelType: 11, + guildId: 'guild-1', + isDirectMessage: false, + isThread: true, + }, + }); + + expect(resolveOrigin).toHaveBeenCalledWith(null, 'session-origin'); + expect(mocks.processFastAgentMessage).toHaveBeenCalledWith( + expect.objectContaining({ + originSessionId: 'session-origin', + eventId: 'new-interaction', + conversationId: 'card-thread', + senderUserId: 'user-1', + sender: { id: 'clicker', username: 'matt' }, + }), + ); + expect(mocks.finalizeWorkItem).toHaveBeenCalledWith(expect.anything(), { + id: 'suggestion-1', + taskId: null, + claimedAt, + }); + expect(mocks.releaseWorkItem).not.toHaveBeenCalled(); + expect(mocks.launchPinned).not.toHaveBeenCalled(); + }); + it('starts a coding task for a pinned suggestion', async () => { const resolveOrigin = vi .spyOn(suggestionLaunch, 'resolveSuggestionOriginSessionId') diff --git a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts index 4c9a427a6..1bd5a0830 100644 --- a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts @@ -10,6 +10,13 @@ const mocks = vi.hoisted(() => ({ recordProviderMessage: vi.fn(), admitHumanFollowUp: vi.fn(), createConversationArtifact: vi.fn(), + resolveSuggestionConversation: vi.fn(), + resolveChannel: vi.fn(), + getSession: vi.fn(), +})); + +vi.mock('../../tasks/suggestion-launch.js', () => ({ + resolveSuggestionFastConversation: mocks.resolveSuggestionConversation, })); vi.mock('@roomote/redis', async (importOriginal) => { @@ -30,9 +37,7 @@ vi.mock('@roomote/cloud-agents/server', () => ({ acquireFastAgentTurnLock: mocks.acquireLock, answerFastAgentQuestion: mocks.answerQuestion, resolveApiBaseUrl: () => 'https://roomote.example.com', - getOrCreateFastAgentSession: vi - .fn() - .mockResolvedValue({ id: 'fast-session-1' }), + getOrCreateFastAgentSession: mocks.getSession, })); vi.mock('@roomote/sdk/server', () => ({ @@ -69,7 +74,21 @@ vi.mock('../thread-context.js', () => ({ })); vi.mock('../task-launch.js', () => ({ - discordMetadataForChannel: vi.fn(), + discordMetadataForChannel: ({ + channel, + messageId, + }: { + channel: { channelId: string; parentChannelId?: string }; + messageId: string; + }) => ({ + communicationProvider: 'discord', + communicationChannelId: channel.parentChannelId ?? channel.channelId, + communicationThreadId: channel.parentChannelId + ? channel.channelId + : undefined, + communicationMessageId: messageId, + }), + resolveDiscordChannelContext: mocks.resolveChannel, resolveDiscordWorkspace: mocks.resolveWorkspace, })); @@ -109,6 +128,7 @@ describe('processDiscordFastAgentMessage', () => { beforeEach(() => { vi.clearAllMocks(); mocks.acquireLock.mockResolvedValue(mocks.releaseLock); + mocks.getSession.mockResolvedValue({ id: 'fast-session-1' }); mocks.admitHumanFollowUp.mockResolvedValue({ kind: 'turn', turnLock: mocks.releaseLock, @@ -129,6 +149,134 @@ describe('processDiscordFastAgentMessage', () => { }); }); + it('accepts a suggestion on its original conversation and sends replies and delegated work to the canonical target', async () => { + const conversation = { + surface: 'discord', + workspaceId: 'guild-1', + conversationId: 'original-report-event', + sessionId: 'origin-session', + replyTarget: { channelId: 'report-channel', threadId: 'report-thread' }, + }; + const channel = { + channelId: 'report-thread', + parentChannelId: 'report-channel', + channelType: 11, + channelName: 'Report', + guildId: 'guild-1', + isThread: true, + isDirectMessage: false, + }; + mocks.resolveSuggestionConversation.mockResolvedValueOnce(conversation); + mocks.resolveChannel.mockResolvedValueOnce(channel); + mocks.fetchHistory.mockResolvedValueOnce([ + { + id: 'history-1', + user: 'author', + username: 'Author', + text: 'Original report', + }, + ]); + const onAccepted = vi.fn(); + const provider = { editMessage: vi.fn().mockResolvedValue(undefined) }; + mocks.answerQuestion.mockImplementationOnce(async ({ adapter }) => { + const reply = await adapter.postReply({ message: 'Working on it' }); + await adapter.replaceReply(reply, { message: 'Updated' }); + await adapter.launchTask({ + prompt: 'Fix errors', + environmentId: ALL_REPOSITORIES, + parentSessionId: 'fast-session-1', + postKickoff: async () => {}, + }); + return null; + }); + await expect( + processDiscordFastAgentMessage({ + eventId: 'new-interaction', + originSessionId: 'origin-session', + question: 'Investigate errors', + sender: { id: 'clicker', username: 'Matt' }, + senderUserId: 'acting-user', + provider: provider as never, + applicationId: 'app-1', + channel: { + ...channel, + channelId: 'card-thread', + parentChannelId: 'card-channel', + }, + metadata: { + communicationChannelId: 'card-channel', + communicationThreadId: 'card-thread', + } as never, + conversationId: 'card-thread', + anchorMessageId: 'clicked-card', + interaction: { + interaction: { id: 'new-interaction', token: 'token' } as never, + interactionDeferred: true, + }, + onAccepted, + }), + ).resolves.toBe(true); + + expect(onAccepted).toHaveBeenCalledWith(expect.any(Function)); + expect(mocks.resolveSuggestionConversation).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'acting-user', + originSessionId: 'origin-session', + conversation: expect.objectContaining({ + conversationId: 'card-thread', + }), + }), + ); + expect(mocks.acquireLock).toHaveBeenCalledWith({ + conversation, + maxWaitMs: 0, + }); + expect(mocks.getSession).toHaveBeenCalledWith({ + userId: 'acting-user', + conversation, + }); + expect(mocks.fetchHistory).toHaveBeenCalledWith({ + provider, + channelId: 'report-thread', + parentChannelId: 'report-channel', + }); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'acting-user', + conversation, + threadContext: [expect.objectContaining({ text: 'Original report' })], + }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ channel }), + ); + expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty('interaction'); + expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty( + 'replyToMessageId', + ); + expect(provider.editMessage).toHaveBeenCalledWith( + expect.objectContaining({ channelId: 'report-thread' }), + ); + expect(mocks.startTask).toHaveBeenCalledWith( + expect.objectContaining({ + channel, + launchOwnerUserId: 'acting-user', + requesterDiscordUserId: 'clicker', + metadata: expect.objectContaining({ + communicationChannelId: 'report-channel', + communicationThreadId: 'report-thread', + }), + queuedMessage: expect.objectContaining({ + channel: 'report-channel', + threadTs: 'report-thread', + userId: 'acting-user', + }), + fastAgentParent: { sessionId: 'fast-session-1', conversation }, + }), + ); + expect(mocks.releaseLock).toHaveBeenCalledOnce(); + }); + it('creates artifacts against the canonical Fast conversation', async () => { mocks.answerQuestion.mockImplementationOnce( async ({ diff --git a/apps/api/src/handlers/discord/callback-actions.ts b/apps/api/src/handlers/discord/callback-actions.ts index 0f8dda449..d9b337215 100644 --- a/apps/api/src/handlers/discord/callback-actions.ts +++ b/apps/api/src/handlers/discord/callback-actions.ts @@ -353,8 +353,13 @@ async function launchClaimedDiscordSuggestion(input: { : {}), }, launch: async (launchMode) => { + const originSessionId = await resolveSuggestionOriginSessionId( + suggestion.sourceTaskId, + suggestion.originSessionId, + ); if (launchMode === 'fast') { const fastStart = await startDiscordFastAgentResponse({ + ...(originSessionId ? { originSessionId } : {}), eventId: input.triggerId, question: promptText, sender: input.sender, @@ -433,10 +438,6 @@ async function launchClaimedDiscordSuggestion(input: { channel: launchChannel, messageId: input.triggerId, }); - const originSessionId = await resolveSuggestionOriginSessionId( - suggestion.sourceTaskId, - suggestion.originSessionId, - ); let launchedRunId: number | null = null; const pinned = await launchPinnedFastSessionTask({ userId: input.senderUserId, diff --git a/apps/api/src/handlers/discord/fast-agent.ts b/apps/api/src/handlers/discord/fast-agent.ts index e8175b279..8b1c6b175 100644 --- a/apps/api/src/handlers/discord/fast-agent.ts +++ b/apps/api/src/handlers/discord/fast-agent.ts @@ -37,9 +37,14 @@ import { type FastAgentDurableTurn, } from '@roomote/sdk/server'; import { appendAttachmentTextsToPromptText } from '@roomote/cloud-agents'; -import { ALL_REPOSITORIES, type TaskInitiator } from '@roomote/types'; +import { + ALL_REPOSITORIES, + type FastAgentConversation, + type TaskInitiator, +} from '@roomote/types'; import { buildCommunicationTaskThreadName } from '../tasks/communication-task-thread.js'; +import { resolveSuggestionFastConversation } from '../tasks/suggestion-launch.js'; import { startAcceptedFastAgentTurn, type FastAgentStartResult, @@ -47,6 +52,7 @@ import { import { replyToDiscordEvent } from './replies.js'; import { discordMetadataForChannel, + resolveDiscordChannelContext, resolveDiscordWorkspace, type DiscordChannelContext, } from './task-launch.js'; @@ -110,6 +116,7 @@ export async function processDiscordFastAgentMessage( channel: DiscordChannelContext; metadata: ReturnType; conversationId: string; + originSessionId?: string; createAnchoredThread?: boolean; /** Real Discord message used for replies and anchored threads. */ anchorMessageId?: string; @@ -139,6 +146,7 @@ export async function processDiscordFastAgentMessage( let metadata = input.metadata; if ( message && + !input.originSessionId && anchorMessageId && input.createAnchoredThread !== false && !channel.isDirectMessage && @@ -165,7 +173,7 @@ export async function processDiscordFastAgentMessage( }; } - const conversation = { + let conversation: Extract = { surface: 'discord' as const, workspaceId: channel.guildId ?? 'dm', conversationId: input.conversationId, @@ -176,6 +184,30 @@ export async function processDiscordFastAgentMessage( : {}), }, }; + if (input.originSessionId) { + const originConversation = await resolveSuggestionFastConversation({ + userId: input.senderUserId, + originSessionId: input.originSessionId, + conversation, + }); + if (originConversation.surface !== 'discord') { + throw new Error('The suggestion origin is not a Discord conversation.'); + } + conversation = originConversation; + const replyChannelId = + conversation.replyTarget.threadId ?? conversation.replyTarget.channelId; + if (replyChannelId !== channel.channelId) { + channel = await resolveDiscordChannelContext( + input.provider, + replyChannelId, + ); + } + metadata = discordMetadataForChannel({ channel, messageId: eventId }); + } + const replyTargetChanged = + Boolean(input.originSessionId) && + channel.channelId !== input.channel.channelId; + const historyChannel = input.originSessionId ? channel : input.channel; let releaseFastAgentLock = await acquireFastAgentTurnLock({ conversation, maxWaitMs: 0, @@ -183,12 +215,12 @@ export async function processDiscordFastAgentMessage( try { const history = - input.channel.isThread || input.channel.isDirectMessage + historyChannel.isThread || historyChannel.isDirectMessage ? await fetchDiscordThreadHistoryBestEffort({ provider: input.provider, - channelId: input.channel.channelId, - ...(input.channel.parentChannelId - ? { parentChannelId: input.channel.parentChannelId } + channelId: historyChannel.channelId, + ...(historyChannel.parentChannelId + ? { parentChannelId: historyChannel.parentChannelId } : {}), }) : []; @@ -283,8 +315,12 @@ export async function processDiscordFastAgentMessage( provider: input.provider, applicationId: input.applicationId, channel, - ...(input.interaction ? { interaction: input.interaction } : {}), - ...(anchorMessageId ? { replyToMessageId: anchorMessageId } : {}), + ...(!replyTargetChanged && input.interaction + ? { interaction: input.interaction } + : {}), + ...(!replyTargetChanged && anchorMessageId + ? { replyToMessageId: anchorMessageId } + : {}), text: textWithFooter, }); await recordFastAgentConversationMessageBestEffort({ diff --git a/apps/api/src/handlers/tasks/suggestion-launch.test.ts b/apps/api/src/handlers/tasks/suggestion-launch.test.ts index 2de66471a..564f86858 100644 --- a/apps/api/src/handlers/tasks/suggestion-launch.test.ts +++ b/apps/api/src/handlers/tasks/suggestion-launch.test.ts @@ -4,6 +4,13 @@ const mocks = vi.hoisted(() => ({ cancel: vi.fn(), getSessionForTask: vi.fn(), findSession: vi.fn(), + findConversation: vi.fn(), + getOrCreateSession: vi.fn(), +})); + +vi.mock('@roomote/cloud-agents/server', () => ({ + fastAgentConversationRepository: { findById: mocks.findConversation }, + getOrCreateFastAgentSession: mocks.getOrCreateSession, })); vi.mock('@roomote/db/server', () => ({ @@ -33,6 +40,7 @@ import { launchClaimedSuggestedTask, resolveSuggestedTaskLaunchMode, resolveSuggestionOriginSessionId, + resolveSuggestionFastConversation, } from './suggestion-launch'; const claimedAt = new Date('2026-08-28T00:00:00.000Z'); @@ -45,6 +53,84 @@ beforeEach(() => { mocks.cancel.mockResolvedValue('orphaned run canceled'); }); +describe('resolveSuggestionFastConversation', () => { + const conversation = { + surface: 'telegram' as const, + workspaceId: 'chat', + conversationId: 'clicked-card', + replyTarget: { channelId: 'chat', threadId: 'clicked-topic' }, + }; + const canonical = { + ...conversation, + conversationId: 'original-conversation', + replyTarget: { channelId: 'chat', threadId: 'original-topic' }, + }; + + it('keeps legacy identity when no origin is available', async () => { + await expect( + resolveSuggestionFastConversation({ userId: 'actor', conversation }), + ).resolves.toBe(conversation); + expect(mocks.findSession).not.toHaveBeenCalled(); + expect(mocks.getOrCreateSession).not.toHaveBeenCalled(); + }); + + it('reuses the origin conversation and reply target without changing its owner', async () => { + mocks.findSession.mockResolvedValue({ + id: 'origin', + fastConversationId: 'original-fast-id', + }); + mocks.findConversation.mockResolvedValue({ + id: 'original-fast-id', + userId: 'different-owner', + conversation: canonical, + }); + await expect( + resolveSuggestionFastConversation({ + userId: 'actor', + originSessionId: 'origin', + conversation, + }), + ).resolves.toBe(canonical); + expect(mocks.findConversation).toHaveBeenCalledWith({ + id: 'original-fast-id', + }); + expect(mocks.getOrCreateSession).not.toHaveBeenCalled(); + }); + + it.each([null, 'missing-fast-id'])( + 'binds an origin without an available conversation (%s) and returns the canonical result', + async (fastConversationId) => { + mocks.findSession.mockResolvedValue({ id: 'origin', fastConversationId }); + mocks.findConversation.mockResolvedValue(null); + mocks.getOrCreateSession.mockResolvedValue({ conversation: canonical }); + await expect( + resolveSuggestionFastConversation({ + userId: 'actor', + originSessionId: 'origin', + conversation, + }), + ).resolves.toBe(canonical); + expect(mocks.getOrCreateSession).toHaveBeenCalledWith({ + userId: 'actor', + sessionId: 'origin', + conversation, + }); + }, + ); + + it('fails without creating a replacement when the origin disappears', async () => { + mocks.findSession.mockResolvedValue(null); + await expect( + resolveSuggestionFastConversation({ + userId: 'actor', + originSessionId: 'origin', + conversation, + }), + ).rejects.toThrow('The suggestion origin Session is no longer available.'); + expect(mocks.getOrCreateSession).not.toHaveBeenCalled(); + }); +}); + describe('resolveSuggestedTaskLaunchMode', () => { it('selects Fast for an eligible suggestion when Fast is the default', () => { expect( diff --git a/apps/api/src/handlers/tasks/suggestion-launch.ts b/apps/api/src/handlers/tasks/suggestion-launch.ts index 8144a2e63..3708924ea 100644 --- a/apps/api/src/handlers/tasks/suggestion-launch.ts +++ b/apps/api/src/handlers/tasks/suggestion-launch.ts @@ -1,3 +1,7 @@ +import { + fastAgentConversationRepository, + getOrCreateFastAgentSession, +} from '@roomote/cloud-agents/server'; import { db, finalizeWorkItemLaunched, @@ -6,7 +10,10 @@ import { eq, sessions, } from '@roomote/db/server'; -import { isDeploymentReadOnlyError } from '@roomote/types'; +import { + isDeploymentReadOnlyError, + type FastAgentConversation, +} from '@roomote/types'; import { resolveFastAgentEntryMode } from '../fast-agent-entry.js'; import { cancelOrphanedWorkItemRunBestEffort } from './orphaned-work-item-run.js'; @@ -214,3 +221,36 @@ export async function resolveSuggestionOriginSessionId( return null; } } + +export async function resolveSuggestionFastConversation(input: { + userId: string; + originSessionId?: string | null; + conversation: FastAgentConversation; +}): Promise { + if (!input.originSessionId) { + return input.conversation; + } + const session = await db.query.sessions.findFirst({ + where: eq(sessions.id, input.originSessionId), + columns: { id: true, fastConversationId: true }, + }); + if (!session) { + throw new Error('The suggestion origin Session is no longer available.'); + } + // Look up the origin before creating by the clicked card's identity: the + // original conversation also owns the reply destination and existing owner. + if (session.fastConversationId) { + const existing = await fastAgentConversationRepository.findById({ + id: session.fastConversationId, + }); + if (existing) { + return existing.conversation; + } + } + const created = await getOrCreateFastAgentSession({ + userId: input.userId, + conversation: input.conversation, + sessionId: session.id, + }); + return created.conversation; +} diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index 48afa30b5..52da1ead7 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -1,6 +1,18 @@ import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as suggestionLaunch from '../../tasks/suggestion-launch.js'; +import * as suggestionStart from '../suggestion-start.js'; +import type { FastAgentConversation } from '@roomote/types'; + +vi.mock('../../tasks/suggestion-launch.js', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../../tasks/suggestion-launch.js') + >()), + resolveSuggestionFastConversation: vi.fn( + async ({ conversation }: { conversation: FastAgentConversation }) => + conversation, + ), +})); const { authAccountsFindFirstMock, @@ -49,6 +61,8 @@ const { findTeamsConversationRouteMock, getFastSessionMock, isFastProviderMessageMock, + finalizeWorkItemMock, + releaseWorkItemMock, } = vi.hoisted(() => ({ authAccountsFindFirstMock: vi.fn(), authAccountsFindManyMock: vi.fn(), @@ -116,6 +130,8 @@ const { findTeamsConversationRouteMock: vi.fn(), getFastSessionMock: vi.fn(), isFastProviderMessageMock: vi.fn(), + finalizeWorkItemMock: vi.fn(), + releaseWorkItemMock: vi.fn(), })); vi.mock('@roomote/env', () => ({ @@ -143,6 +159,8 @@ vi.mock('../suggestion-start.js', () => ({ })); vi.mock('@roomote/db/server', () => ({ + finalizeWorkItemLaunched: finalizeWorkItemMock, + releaseWorkItemClaim: releaseWorkItemMock, and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), setTrustedRunActingUser: setTrustedRunActingUserMock, getSessionForTask: getSessionForTaskMock, @@ -835,6 +853,134 @@ describe('Teams webhook handler', () => { ); }); + it.each(['reaction', 'numbered text'] as const)( + 'dispatches a taskless router suggestion from %s in its canonical Fast conversation', + async (path) => { + const resolveOrigin = vi + .spyOn(suggestionLaunch, 'resolveSuggestionOriginSessionId') + .mockResolvedValueOnce('session-origin'); + const canonicalConversation = { + surface: 'teams' as const, + workspaceId: 'tenant-1', + conversationId: 'original-report:user:report-owner', + replyTarget: { + channelId: '19:original-channel@thread.v2', + threadId: 'original-report', + }, + }; + const resolveConversation = vi + .spyOn(suggestionLaunch, 'resolveSuggestionFastConversation') + .mockResolvedValueOnce(canonicalConversation); + const suggestion = { + id: 'suggestion-router', + title: 'Investigate the report', + brief: 'Follow up on the findings.', + investigationContext: null, + targetRepositoryFullName: null, + targetEnvironmentId: null, + usesRouterLaunch: true, + sourceTaskId: null, + originSessionId: 'session-card', + launchClaimedAt: new Date('2026-08-07T00:00:00.000Z'), + }; + teamsUserMappingFindFirstMock.mockResolvedValue({ + userId: 'mapped-user-1', + }); + findFirstMock.mockResolvedValue(null); + if (path === 'reaction') { + trackedSuggestionMessageFindFirstMock.mockResolvedValue({ + workItemId: suggestion.id, + }); + resolveAndClaimTeamsSuggestionReactionMock.mockResolvedValue({ + outcome: 'claimed', + suggestion, + }); + } else { + vi.mocked( + suggestionStart.parseTeamsSuggestionStartText, + ).mockReturnValueOnce(1); + vi.mocked( + suggestionStart.resolveAndClaimTeamsSuggestionStart, + ).mockResolvedValueOnce({ outcome: 'claimed', suggestion }); + } + const actual = await vi.importActual< + typeof import('../suggestion-start.js') + >('../suggestion-start.js'); + launchClaimedTeamsSuggestionMock.mockImplementationOnce( + actual.launchClaimedTeamsSuggestion, + ); + finalizeWorkItemMock.mockResolvedValueOnce(true); + const abort = vi.fn(); + getFastSessionMock.mockImplementationOnce(async ({ conversation }) => { + expect(conversation).toEqual(canonicalConversation); + return { id: 'fast-original', conversation: canonicalConversation }; + }); + continueFastReplyMock.mockImplementationOnce(async ({ onAccepted }) => { + onAccepted(abort); + return true; + }); + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer valid-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + id: 'new-interaction', + replyToId: 'clicked-card', + ...(path === 'reaction' + ? { + type: 'messageReaction', + text: undefined, + entities: undefined, + reactionsAdded: [{ type: 'like' }], + } + : { text: 'Roomote start idea 1' }), + }), + ), + }); + await expect(response.json()).resolves.toEqual({ + ok: true, + started: true, + runId: null, + }); + expect(resolveOrigin).toHaveBeenCalledWith(null, 'session-card'); + expect(resolveConversation).toHaveBeenCalledWith({ + userId: 'mapped-user-1', + originSessionId: 'session-origin', + conversation: expect.objectContaining({ + conversationId: 'clicked-card:user:mapped-user-1', + replyTarget: { + channelId: '19:conversation@thread.v2', + threadId: 'clicked-card', + }, + }), + }); + expect(getFastSessionMock).toHaveBeenCalledWith({ + userId: 'mapped-user-1', + conversation: canonicalConversation, + }); + expect(continueFastReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'fast-original', + userId: 'mapped-user-1', + currentMessageId: 'new-interaction', + question: expect.stringContaining('Investigate the report'), + }), + ); + expect(finalizeWorkItemMock).toHaveBeenCalledWith(expect.anything(), { + id: suggestion.id, + taskId: null, + claimedAt: suggestion.launchClaimedAt, + }); + expect(releaseWorkItemMock).not.toHaveBeenCalled(); + expect(abort).not.toHaveBeenCalled(); + expect(launchPinnedMock).not.toHaveBeenCalled(); + expect(enqueueTaskMock).not.toHaveBeenCalled(); + }, + ); + it('queues a native reaction on the owner’s bound Fast message', async () => { teamsUserMappingFindFirstMock.mockResolvedValue({ userId: 'mapped-user-1', diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index c2435f8f0..a55297d39 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -120,7 +120,10 @@ import { resolveAndClaimTeamsSuggestionReaction, type ClaimedTeamsSuggestion, } from './suggestion-start.js'; -import { resolveSuggestionOriginSessionId } from '../tasks/suggestion-launch.js'; +import { + resolveSuggestionFastConversation, + resolveSuggestionOriginSessionId, +} from '../tasks/suggestion-launch.js'; import { shouldRouteUnmentionedTeamsThreadReplyToAgent } from './unmentioned-thread-reply.js'; const TEAMS_ACTIVITY_DEDUP_PREFIX = 'teams:activity:'; @@ -233,13 +236,14 @@ function resolveTeamsFastConversation(params: { const TEAMS_FAST_UNAVAILABLE_MESSAGE = "Roomote couldn't start a conversation right now. Please try again in a moment."; -function startTeamsFastSuggestion(params: { +async function startTeamsFastSuggestion(params: { activity: TeamsActivity; metadata: TeamsActivityCommunicationMetadata; mappedUserId: string; prompt: string; currentMessageId: string; images?: string[]; + originSessionId?: string | null; }): Promise { const conversation = resolveTeamsFastConversation(params); if (!conversation) { @@ -248,11 +252,16 @@ function startTeamsFastSuggestion(params: { reason: 'Fast mode is unavailable in this Teams conversation.', }); } + const canonicalConversation = await resolveSuggestionFastConversation({ + userId: params.mappedUserId, + originSessionId: params.originSessionId, + conversation, + }); return startAcceptedFastAgentTurn({ run: async ({ onAccepted, onRejected }) => { const session = await getOrCreateFastAgentSession({ userId: params.mappedUserId, - conversation, + conversation: canonicalConversation, }); return continueFastAgentSurfaceReply({ sessionId: session.id, @@ -2172,8 +2181,12 @@ teams.post('/', async (c) => { } as QueuedTeamsCommunicationMessage, workspace: workspaceOverride!, }), - launchFast: (promptText) => + launchFast: async (promptText) => startTeamsFastSuggestion({ + originSessionId: await resolveSuggestionOriginSessionId( + claimedSuggestionReaction.sourceTaskId, + claimedSuggestionReaction.originSessionId, + ), activity, metadata, mappedUserId: mappedUserId!, @@ -2419,8 +2432,12 @@ teams.post('/', async (c) => { queuedMessage: { ...queuedMessage!, text: promptText }, workspace: workspaceOverride!, }), - launchFast: (promptText) => + launchFast: async (promptText) => startTeamsFastSuggestion({ + originSessionId: await resolveSuggestionOriginSessionId( + resolution.suggestion.sourceTaskId, + resolution.suggestion.originSessionId, + ), activity, metadata, mappedUserId, diff --git a/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts b/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts index 1e65b18e5..8805fddae 100644 --- a/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts @@ -28,6 +28,7 @@ const { continueFastAgentSurfaceReplyMock, getOrCreateFastAgentSessionMock, fastAbortMock, + resolveSuggestionFastConversationMock, } = vi.hoisted(() => ({ answerCallbackMock: vi.fn(), apiLoggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -46,6 +47,12 @@ const { continueFastAgentSurfaceReplyMock: vi.fn(), getOrCreateFastAgentSessionMock: vi.fn(), fastAbortMock: vi.fn(), + resolveSuggestionFastConversationMock: vi.fn(), +})); + +vi.mock('../../tasks/suggestion-launch.js', async (importOriginal) => ({ + ...(await importOriginal()), + resolveSuggestionFastConversation: resolveSuggestionFastConversationMock, })); vi.mock('@roomote/sdk/server', () => ({ @@ -210,6 +217,9 @@ beforeEach(() => { workspaceDisplayName: 'App', }); getOrCreateFastAgentSessionMock.mockResolvedValue({ id: 'session-1' }); + resolveSuggestionFastConversationMock.mockImplementation( + async ({ conversation }) => conversation, + ); fastAbortMock.mockResolvedValue(undefined); // Admission fires before the turn runs; the default turn then completes. continueFastAgentSurfaceReplyMock.mockImplementation( @@ -300,7 +310,10 @@ describe('handleTelegramCallbackQuery suggestion launch lifecycle', () => { ); }); - it('lets Fast decide for router-backed suggestions instead of launching directly', async () => { + it('keeps the taskless router card origin when dispatching to Fast', async () => { + const resolveOrigin = vi + .spyOn(suggestionLaunch, 'resolveSuggestionOriginSessionId') + .mockResolvedValueOnce('session-origin'); claimTelegramSuggestionLaunchMock.mockResolvedValue({ id: WORK_ITEM_ID, title: 'Fix the flaky test', @@ -308,11 +321,24 @@ describe('handleTelegramCallbackQuery suggestion launch lifecycle', () => { investigationContext: null, targetRepositoryFullName: null, usesRouterLaunch: true, + sourceTaskId: null, + originSessionId: 'session-card', launchClaimedAt: CLAIMED_AT, }); await handleTelegramCallbackQuery(buildSuggestionQuery()); + expect(resolveOrigin).toHaveBeenCalledWith(null, 'session-card'); + expect(resolveSuggestionFastConversationMock).toHaveBeenCalledWith({ + userId: 'user-1', + originSessionId: 'session-origin', + conversation: { + surface: 'telegram', + workspaceId: '555', + conversationId: '555:user:user-1', + replyTarget: { channelId: '555' }, + }, + }); expect(continueFastAgentSurfaceReplyMock).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'session-1', @@ -327,6 +353,103 @@ describe('handleTelegramCallbackQuery suggestion launch lifecycle', () => { ); }); + it('admits the Fast turn on the original conversation and canonical reply target, not the clicked topic', async () => { + vi.spyOn( + suggestionLaunch, + 'resolveSuggestionOriginSessionId', + ).mockResolvedValueOnce('session-origin'); + claimTelegramSuggestionLaunchMock.mockResolvedValueOnce({ + id: WORK_ITEM_ID, + title: 'Fix the flaky test', + brief: 'The retry loop never terminates.', + targetRepositoryFullName: '__fast__', + launchTarget: '__fast__', + sourceTaskId: null, + originSessionId: 'session-origin', + launchClaimedAt: CLAIMED_AT, + }); + const canonicalConversation = { + surface: 'telegram' as const, + workspaceId: '555', + conversationId: '11:user:report-owner', + replyTarget: { channelId: '555', threadId: '11' }, + }; + resolveSuggestionFastConversationMock.mockResolvedValueOnce( + canonicalConversation, + ); + getOrCreateFastAgentSessionMock.mockImplementationOnce(async (input) => { + expect(input).toEqual({ + userId: 'user-1', + conversation: canonicalConversation, + }); + return { id: 'fast-origin', conversation: canonicalConversation }; + }); + continueFastAgentSurfaceReplyMock.mockImplementationOnce( + ({ sessionId, userId, onAccepted }) => { + expect(sessionId).toBe('fast-origin'); + expect(userId).toBe('user-1'); + expect(finalizeWorkItemLaunchedMock).not.toHaveBeenCalled(); + onAccepted(fastAbortMock); + return new Promise(() => {}); + }, + ); + + await handleTelegramCallbackQuery(buildSuggestionQuery(44)); + + expect(resolveSuggestionFastConversationMock).toHaveBeenCalledWith({ + userId: 'user-1', + originSessionId: 'session-origin', + conversation: { + surface: 'telegram', + workspaceId: '555', + conversationId: '44:user:user-1', + replyTarget: { channelId: '555', threadId: '44' }, + }, + }); + expect(continueFastAgentSurfaceReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'fast-origin', + userId: 'user-1', + currentMessageId: '100', + }), + ); + expect(finalizeWorkItemLaunchedMock).toHaveBeenCalledWith( + expect.anything(), + { id: WORK_ITEM_ID, taskId: null, claimedAt: CLAIMED_AT }, + ); + expect(releaseWorkItemClaimMock).not.toHaveBeenCalled(); + expect(fastAbortMock).not.toHaveBeenCalled(); + expect(launchPinnedMock).not.toHaveBeenCalled(); + expect(launchTelegramTaskMock).not.toHaveBeenCalled(); + }); + + it('releases the fenced claim if taskless origin resolution fails before Fast dispatch', async () => { + vi.spyOn( + suggestionLaunch, + 'resolveSuggestionOriginSessionId', + ).mockRejectedValueOnce(new Error('Origin unavailable')); + claimTelegramSuggestionLaunchMock.mockResolvedValueOnce({ + id: WORK_ITEM_ID, + title: 'Fix the flaky test', + brief: 'The retry loop never terminates.', + usesRouterLaunch: true, + sourceTaskId: null, + originSessionId: 'session-origin', + launchClaimedAt: CLAIMED_AT, + }); + + await handleTelegramCallbackQuery(buildSuggestionQuery(44)); + + expect(continueFastAgentSurfaceReplyMock).not.toHaveBeenCalled(); + expect(resolveSuggestionFastConversationMock).not.toHaveBeenCalled(); + expect(finalizeWorkItemLaunchedMock).not.toHaveBeenCalled(); + expect(releaseWorkItemClaimMock).toHaveBeenCalledTimes(1); + expect(releaseWorkItemClaimMock).toHaveBeenCalledWith(expect.anything(), { + id: WORK_ITEM_ID, + claimedAt: CLAIMED_AT, + }); + }); + it('launches directly in the environment saved on the suggestion', async () => { claimTelegramSuggestionLaunchMock.mockResolvedValue({ id: WORK_ITEM_ID, diff --git a/apps/api/src/handlers/telegram/callback-actions.ts b/apps/api/src/handlers/telegram/callback-actions.ts index 3cebdb265..f52f1765c 100644 --- a/apps/api/src/handlers/telegram/callback-actions.ts +++ b/apps/api/src/handlers/telegram/callback-actions.ts @@ -31,6 +31,7 @@ import { apiLogger } from '../../logging.js'; import { startAcceptedFastAgentTurn } from '../fast-agent-entry.js'; import { launchClaimedSuggestedTask, + resolveSuggestionFastConversation, resolveSuggestionOriginSessionId, } from '../tasks/suggestion-launch.js'; import { @@ -283,6 +284,10 @@ async function handleSuggestionLaunchCallback(params: { const claimedAt = suggestion.launchClaimedAt; try { + const originSessionId = await resolveSuggestionOriginSessionId( + suggestion.sourceTaskId, + suggestion.originSessionId, + ); const launchTarget = resolveSuggestedTaskLaunchTarget(suggestion); const pinnedEnvironmentId = resolveSuggestedTaskPinnedEnvironmentId( launchTarget, @@ -343,7 +348,11 @@ async function handleSuggestionLaunchCallback(params: { if (mode === 'fast') { const session = await getOrCreateFastAgentSession({ userId: senderUserId, - conversation, + conversation: await resolveSuggestionFastConversation({ + userId: senderUserId, + originSessionId, + conversation, + }), }); // Resolve on admission, not on turn completion: the claim is // finalized as soon as the Fast session accepts the follow-up, and @@ -386,10 +395,6 @@ async function handleSuggestionLaunchCallback(params: { threadId, forceNewTopic: true, }); - const originSessionId = await resolveSuggestionOriginSessionId( - suggestion.sourceTaskId, - suggestion.originSessionId, - ); let launchedRunId: number | null = null; const pinned = await launchPinnedFastSessionTask({ userId: senderUserId,