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 ff1bbf523..a07dd8eec 100644 --- a/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts +++ b/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts @@ -219,7 +219,7 @@ describe('Discord setup suggestions', () => { }); expect(findTrackedCardMock).toHaveBeenCalledWith( - expect.objectContaining({ columns: { id: true } }), + expect.objectContaining({ columns: { id: true, metadata: true } }), ); expect(claimWorkItemMock).toHaveBeenCalledWith(expect.anything(), { id: 'suggestion-1', @@ -237,4 +237,31 @@ describe('Discord setup suggestions', () => { }), ).resolves.toBeNull(); }); + + it('discards pinned metadata for router-launched suggestion cards', async () => { + findTrackedCardMock.mockResolvedValue({ + id: 'tracked-1', + metadata: { launchRouting: 'router' }, + }); + claimWorkItemMock.mockResolvedValue({ + id: 'suggestion-1', + title: 'Fix tests', + brief: 'Repair the flaky test.', + investigationContext: 'Legacy context.', + targetRepositoryFullName: 'wrong/repo', + targetEnvironmentId: 'wrong-environment', + launchClaimedAt: new Date('2026-07-12T12:00:00.000Z'), + }); + + const claim = await claimDiscordSuggestionLaunch({ + suggestionId: 'suggestion-1', + channelId: 'thread-1', + }); + + expect(claim).toMatchObject({ + investigationContext: null, + targetRepositoryFullName: null, + targetEnvironmentId: null, + }); + }); }); diff --git a/apps/api/src/handlers/discord/automation-suggestions.ts b/apps/api/src/handlers/discord/automation-suggestions.ts index b4b0dd3fa..6dc5b5f00 100644 --- a/apps/api/src/handlers/discord/automation-suggestions.ts +++ b/apps/api/src/handlers/discord/automation-suggestions.ts @@ -27,6 +27,7 @@ export async function postCurrentThreadSuggestionsToDiscord(params: { sourceTaskId: string; suggestionGroupKey: string; createdByUserId: string | null; + launchRouting?: 'router'; channelId: string; threadId?: string | null; suggestions: DiscordAutomationSuggestion[]; @@ -66,6 +67,9 @@ export async function postCurrentThreadSuggestionsToDiscord(params: { suggestionType: 'suggested_tasks', suggestionKey: `${params.sourceTaskId}:${suggestion.id}`, suggestionGroupKey: params.suggestionGroupKey, + ...(params.launchRouting + ? { launchRouting: params.launchRouting } + : {}), }, }; await db diff --git a/apps/api/src/handlers/discord/setup-suggestions.ts b/apps/api/src/handlers/discord/setup-suggestions.ts index 33b0bb64b..62430062d 100644 --- a/apps/api/src/handlers/discord/setup-suggestions.ts +++ b/apps/api/src/handlers/discord/setup-suggestions.ts @@ -194,7 +194,7 @@ export async function claimDiscordSuggestionLaunch(input: { eq(trackedMessages.channelId, input.channelId), eq(trackedMessages.workItemId, input.suggestionId), ), - columns: { id: true }, + columns: { id: true, metadata: true }, }); if (!trackedCard) { @@ -206,13 +206,19 @@ export async function claimDiscordSuggestionLaunch(input: { return null; } + // Cards marked launchRouting: 'router' are presentation-only chat-reply + // suggestions; drop their pinned launch metadata so the task router selects + // the workspace. Unmarked cards (scan and setup) keep their verified + // targets. + const routed = trackedCard.metadata?.launchRouting === 'router'; + return { id: claimed.id, title: claimed.title, brief: claimed.brief, - investigationContext: claimed.investigationContext, - targetRepositoryFullName: claimed.targetRepositoryFullName, - targetEnvironmentId: claimed.targetEnvironmentId, + investigationContext: routed ? null : claimed.investigationContext, + targetRepositoryFullName: routed ? null : claimed.targetRepositoryFullName, + targetEnvironmentId: routed ? null : claimed.targetEnvironmentId, 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 f78f3ab66..d3a04c2e6 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 @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ trackedMessageFindFirst: vi.fn(), resolveWorkspace: vi.fn(), lookupSlackUserMapping: vi.fn(), + startAutoRoutedSlackTask: vi.fn(), startSlackAppMentionTask: vi.fn(), postStartedMessage: vi.fn(), getConfiguration: vi.fn(), @@ -88,6 +89,7 @@ vi.mock('@roomote/slack', () => ({ ackEmoji: 'eyes', completionEmoji: 'white_check_mark', })), + startAutoRoutedSlackTask: mocks.startAutoRoutedSlackTask, startSlackAppMentionTask: mocks.startSlackAppMentionTask, })); @@ -138,8 +140,14 @@ describe('chat reply suggestion reactions', () => { mocks.trackedMessageFindFirst.mockResolvedValue({ id: 'tracked-message-1', workItemId: 'work-item-1', - metadata: { suggestionType: 'suggested_tasks' }, + metadata: { suggestionType: 'suggested_tasks', launchRouting: 'router' }, + }); + mocks.lookupSlackUserMapping.mockResolvedValue({ + hasInactiveMapping: false, + activeMapping: { userId: 'user-1' }, }); + mocks.claimWorkItem.mockResolvedValue({ launchClaimedAt: claimedAt }); + mocks.finalizeWorkItemLaunched.mockResolvedValue(true); mocks.resolveWorkspace.mockResolvedValue({ workspace: { repoForPayload: 'acme/app', @@ -148,12 +156,12 @@ describe('chat reply suggestion reactions', () => { }, failureReason: null, }); - mocks.lookupSlackUserMapping.mockResolvedValue({ - hasInactiveMapping: false, - activeMapping: { userId: 'user-1' }, + mocks.startAutoRoutedSlackTask.mockResolvedValue({ + status: 'started', + threadId: 'seeded-thread-ts', + runId: 42, + taskId: 'task-new', }); - mocks.claimWorkItem.mockResolvedValue({ launchClaimedAt: claimedAt }); - mocks.finalizeWorkItemLaunched.mockResolvedValue(true); mocks.startSlackAppMentionTask.mockResolvedValue({ id: 42, taskId: 'task-new', @@ -170,7 +178,7 @@ describe('chat reply suggestion reactions', () => { await handleReactionAddedEvent({ context: { teamId: 'T1', - slackInstallation: { botUserId: 'UROOMOTE' }, + slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, slack, } as never, event: { @@ -182,19 +190,15 @@ describe('chat reply suggestion reactions', () => { }, }); - expect(mocks.resolveWorkspace).toHaveBeenCalledWith({ - targetRepositoryFullName: 'acme/app', - targetEnvironmentId: 'environment-1', - readinessMessage: null, - }); - expect(mocks.startSlackAppMentionTask).toHaveBeenCalledWith( + expect(mocks.startAutoRoutedSlackTask).toHaveBeenCalledWith( expect.objectContaining({ channel: 'C1', - repo: 'acme/app', - environmentId: 'environment-1', - agentPromptText: 'implementation prompt', + prompt: + 'Start this suggested task: Add retry telemetry\n\nInstrument retry exhaustion.', + agentPromptTextOverride: 'implementation prompt', }), ); + expect(mocks.startSlackAppMentionTask).not.toHaveBeenCalled(); expect(mocks.finalizeWorkItemLaunched).toHaveBeenCalledWith( expect.anything(), { @@ -203,12 +207,97 @@ describe('chat reply suggestion reactions', () => { claimedAt, }, ); - expect(mocks.postStartedMessage).toHaveBeenCalledWith( + expect(mocks.postStartedMessage).not.toHaveBeenCalled(); + }); + + it('releases the suggestion when routing cannot choose a workspace', async () => { + mocks.startAutoRoutedSlackTask.mockResolvedValue({ + status: 'not_started', + code: 'routing_fallback', + threadId: 'seeded-thread-ts', + message: 'Slack auto-routing needs manual environment selection.', + }); + const slack = { + postMessage: vi + .fn() + .mockResolvedValueOnce('seeded-thread-ts') + .mockResolvedValueOnce('failure-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', + }, + }); + + expect(mocks.releaseWorkItemClaim).toHaveBeenCalledWith(expect.anything(), { + id: 'work-item-1', + claimedAt, + }); + expect(mocks.finalizeWorkItemLaunched).not.toHaveBeenCalled(); + expect(slack.deleteMessage).toHaveBeenCalledWith({ + channel: 'C1', + ts: 'seeded-thread-ts', + }); + expect(slack.postMessage).toHaveBeenLastCalledWith( expect.objectContaining({ - channelId: 'C1', - threadTs: 'seeded-thread-ts', - taskId: 'task-new', + channel: 'C1', + text: expect.stringContaining( + 'Slack auto-routing needs manual environment selection.', + ), + }), + ); + }); + + it('keeps unmarked suggestion cards pinned to their verified workspace', async () => { + mocks.trackedMessageFindFirst.mockResolvedValue({ + id: 'tracked-message-1', + workItemId: 'work-item-1', + metadata: { suggestionType: 'suggested_tasks' }, + }); + 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', + }, + }); + + expect(mocks.resolveWorkspace).toHaveBeenCalledWith({ + targetRepositoryFullName: 'acme/app', + targetEnvironmentId: 'environment-1', + readinessMessage: null, + }); + expect(mocks.startSlackAppMentionTask).toHaveBeenCalledWith( + expect.objectContaining({ + repo: 'acme/app', + environmentId: 'environment-1', }), ); + expect(mocks.startAutoRoutedSlackTask).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/handlers/slack/events/reactions.ts b/apps/api/src/handlers/slack/events/reactions.ts index 3f2103777..b95702ccb 100644 --- a/apps/api/src/handlers/slack/events/reactions.ts +++ b/apps/api/src/handlers/slack/events/reactions.ts @@ -17,6 +17,7 @@ import { } from '../helpers/suggestion-workspace.js'; import { resolveSlackReactionNames, + startAutoRoutedSlackTask, startSlackAppMentionTask, type SlackNotifier, type SlackReactionAddedEvent, @@ -175,12 +176,14 @@ const REMOVED_SLACK_ACCOUNT_LAUNCH_FAILURE = async function launchTaskSuggestionTaskFromReaction({ teamId, + slackInstallation, slack, reactionEvent, ackEmoji, completionEmoji, }: { teamId: string; + slackInstallation: SlackWebhookContext['slackInstallation']; slack: SlackNotifier; reactionEvent: SlackReactionAddedEvent; ackEmoji: string; @@ -300,6 +303,13 @@ async function launchTaskSuggestionTaskFromReaction({ } const suggestionBrief = workItem.brief ?? ''; + // Cards marked launchRouting: 'router' are presentation-only chat-reply + // suggestions; the task router selects the workspace at launch. Unmarked + // cards (scan, setup, and cards posted before the marker existed) launch + // pinned to their persisted targets. + const usesRouterLaunch = + suggestionType === 'suggested_tasks' && + suggestionCard.metadata?.launchRouting === 'router'; let suggestionWorkspace: SuggestionLaunchWorkspace | null = null; let launchFailureReason: string | null = null; @@ -370,7 +380,7 @@ async function launchTaskSuggestionTaskFromReaction({ workspaceDisplayName: matchingEnvironment.name, }; } - } else if (suggestionType === 'suggested_tasks') { + } else if (suggestionType === 'suggested_tasks' && !usesRouterLaunch) { const resolved = await resolveSuggestionLaunchWorkspaceFromMetadata({ targetRepositoryFullName: workItem.targetRepositoryFullName, targetEnvironmentId: workItem.targetEnvironmentId, @@ -378,7 +388,7 @@ async function launchTaskSuggestionTaskFromReaction({ }); suggestionWorkspace = resolved.workspace; launchFailureReason = resolved.failureReason; - } else { + } else if (suggestionType !== 'suggested_tasks') { return false; } @@ -414,7 +424,7 @@ async function launchTaskSuggestionTaskFromReaction({ // reclaimed cannot stomp the new claimant's state. const claimedAt = claimedWorkItem.launchClaimedAt; - if (!suggestionWorkspace) { + if (!usesRouterLaunch && !suggestionWorkspace) { await releaseWorkItemClaim(db, { id: workItemId, claimedAt }); apiLogger.warn( @@ -441,15 +451,9 @@ async function launchTaskSuggestionTaskFromReaction({ return true; } - const suggestionSlackTargetRepositoryFullName = - workItem.targetRepositoryFullName; - const suggestionSlackText = buildSuggestionSlackText({ title: workItem.title, brief: suggestionBrief, - category: workItem.category, - priority: workItem.priority, - targetRepositoryFullName: suggestionSlackTargetRepositoryFullName, }); const seededSuggestionSlackText = buildSeededSuggestionSlackText( suggestionSlackText, @@ -459,17 +463,20 @@ async function launchTaskSuggestionTaskFromReaction({ title: workItem.title, brief: suggestionBrief, investigationContext: workItem.investigationContext, - readinessMessage: - suggestionWorkspace.readinessMessage ?? workItem.readinessMessage, + readinessMessage: !usesRouterLaunch + ? (suggestionWorkspace?.readinessMessage ?? workItem.readinessMessage) + : null, suggestionType, category: workItem.category, priority: workItem.priority, - targetRepositoryFullName: suggestionSlackTargetRepositoryFullName, + targetRepositoryFullName: !usesRouterLaunch + ? workItem.targetRepositoryFullName + : null, }); let seededThreadTs: string | undefined; - let taskRun: Awaited> | null = - null; + let taskRun: { id: number | null; taskId: string | null } | null = null; + const directWorkspaceName = suggestionWorkspace?.workspaceDisplayName ?? null; try { seededThreadTs = await slack.postMessage({ channel: channelId, @@ -490,38 +497,83 @@ async function launchTaskSuggestionTaskFromReaction({ return false; } - // The reacting human is the initiator; the old fallback to the - // suggestion creator's identity is gone. - taskRun = await startSlackAppMentionTask({ - initiator: { - kind: 'user', - externalId: reactionEvent.user, - ...(reactingUserMapping.activeMapping?.userId - ? { matchedUserId: reactingUserMapping.activeMapping.userId } - : {}), - }, - trigger: 'manual', - channel: channelId, - teamId, - slackUserId: reactionEvent.user, - text: suggestionSlackText, - agentPromptText: suggestionTaskPrompt, - ts: seededThreadTs, - threadTs: seededThreadTs, - repo: suggestionWorkspace.repoForPayload, - environmentId: suggestionWorkspace.environmentId, - readinessMessage: suggestionWorkspace.readinessMessage ?? undefined, - webPath: suggestionType === 'setup_onboarding' ? '/setup' : undefined, - ackEmoji, - completionEmoji, - queuedStartedMessage: { - ts: seededThreadTs, - agentName: AGENT_DISPLAY_NAME, + const initiator = { + kind: 'user' as const, + externalId: reactionEvent.user, + ...(reactingUserMapping.activeMapping?.userId + ? { matchedUserId: reactingUserMapping.activeMapping.userId } + : {}), + }; + + if (usesRouterLaunch) { + const routedLaunch = await startAutoRoutedSlackTask({ + slackInstallation, + slack, + initiator, + trigger: 'manual', + launchUserId: reactingUserMapping.activeMapping?.userId, + slackUserId: reactionEvent.user, + persistedSlackUserId: reactionEvent.user, initiatingSlackUserId: reactionEvent.user, - workspaceDisplayName: suggestionWorkspace.workspaceDisplayName, - workspaceOnly: false, - }, - }); + channel: channelId, + prompt: `Start this suggested task: ${workItem.title}\n\n${suggestionBrief}`, + threadTs: seededThreadTs, + originMessageTs: seededThreadTs, + agentPromptTextOverride: suggestionTaskPrompt, + skipMcpSetupSuggestion: true, + }); + + if (routedLaunch.status !== 'started') { + await releaseWorkItemClaim(db, { id: workItemId, claimedAt }); + await slack + .deleteMessage({ channel: channelId, ts: seededThreadTs }) + .catch(() => {}); + await postSuggestionLaunchFailureMessage({ + slack, + channelId, + title: workItem.title, + brief: suggestionBrief, + reason: + routedLaunch.message || + "I couldn't determine which workspace should run this suggestion.", + }); + return true; + } + + taskRun = { + id: routedLaunch.runId, + taskId: routedLaunch.taskId, + }; + } else { + if (!suggestionWorkspace) { + throw new Error('Setup suggestion workspace was not resolved.'); + } + + taskRun = await startSlackAppMentionTask({ + initiator, + trigger: 'manual', + channel: channelId, + teamId, + slackUserId: reactionEvent.user, + text: suggestionSlackText, + agentPromptText: suggestionTaskPrompt, + ts: seededThreadTs, + threadTs: seededThreadTs, + repo: suggestionWorkspace.repoForPayload, + environmentId: suggestionWorkspace.environmentId, + readinessMessage: suggestionWorkspace.readinessMessage ?? undefined, + webPath: suggestionType === 'setup_onboarding' ? '/setup' : undefined, + ackEmoji, + completionEmoji, + queuedStartedMessage: { + ts: seededThreadTs, + agentName: AGENT_DISPLAY_NAME, + initiatingSlackUserId: reactionEvent.user, + workspaceDisplayName: suggestionWorkspace.workspaceDisplayName, + workspaceOnly: false, + }, + }); + } const launched = await markWorkItemLaunched({ workItemId, @@ -555,15 +607,17 @@ async function launchTaskSuggestionTaskFromReaction({ return true; } - await postTaskSuggestionStartedMessage({ - slack, - channelId, - threadTs: seededThreadTs, - workspaceName: suggestionWorkspace.workspaceDisplayName, - runId: taskRun.id, - initiatingSlackUserId: reactionEvent.user, - taskId: taskRun.taskId, - }); + if (!usesRouterLaunch && directWorkspaceName) { + await postTaskSuggestionStartedMessage({ + slack, + channelId, + threadTs: seededThreadTs, + workspaceName: directWorkspaceName, + runId: taskRun.id, + initiatingSlackUserId: reactionEvent.user, + taskId: taskRun.taskId, + }); + } apiLogger.debug( `${logPrefix} completed reaction launch lifecycle taskId=${taskRun.taskId ?? 'null'} launchedThreadTs=${seededThreadTs}`, @@ -623,20 +677,22 @@ async function launchTaskSuggestionTaskFromReaction({ `${logPrefix} reaction launch recovered after post-enqueue failure taskId=${taskRun.taskId} launchedThreadTs=${seededThreadTs ?? 'unknown'}`, ); - if (seededThreadTs) { - await postTaskSuggestionStartedMessage({ - slack, - channelId, - threadTs: seededThreadTs, - workspaceName: suggestionWorkspace.workspaceDisplayName, - runId: taskRun.id, - initiatingSlackUserId: reactionEvent.user, - taskId: taskRun.taskId, - }); - } else { - console.warn( - `${logPrefix} recovered launch missing seeded thread ts; started message skipped`, - ); + if (!usesRouterLaunch) { + if (seededThreadTs && directWorkspaceName) { + await postTaskSuggestionStartedMessage({ + slack, + channelId, + threadTs: seededThreadTs, + workspaceName: directWorkspaceName, + runId: taskRun.id, + initiatingSlackUserId: reactionEvent.user, + taskId: taskRun.taskId, + }); + } else { + console.warn( + `${logPrefix} recovered direct launch missing seeded thread or workspace; started message skipped`, + ); + } } apiLogger.debug( @@ -776,6 +832,7 @@ export async function handleReactionAddedEvent(params: { launch: () => launchTaskSuggestionTaskFromReaction({ teamId: context.teamId, + slackInstallation: context.slackInstallation, slack: context.slack, reactionEvent: event, ackEmoji: reactionNames.ackEmoji, diff --git a/apps/api/src/handlers/slack/helpers/suggestion-slack-text.ts b/apps/api/src/handlers/slack/helpers/suggestion-slack-text.ts index ce8de0e86..6566cf7fc 100644 --- a/apps/api/src/handlers/slack/helpers/suggestion-slack-text.ts +++ b/apps/api/src/handlers/slack/helpers/suggestion-slack-text.ts @@ -9,10 +9,7 @@ import { SUGGESTION_PRIORITY_LABELS, } from '@roomote/types'; -type SuggestionBadgeStyle = 'full' | 'color_only'; - type SuggestionSlackTextOptions = { - badgeStyle?: SuggestionBadgeStyle; quote?: boolean; }; @@ -39,7 +36,6 @@ export function getSharedScheduledSuggestionSlackTextOptions( } return { - badgeStyle: 'color_only', quote: true, }; } @@ -60,27 +56,12 @@ export function buildSuggestionSlackText( params: { title: string; brief: string; - category?: string | null; - priority?: string | null; - targetRepositoryFullName?: string | null; footerText?: string | null; }, options: SuggestionSlackTextOptions = {}, ): string { - const repoLabel = params.targetRepositoryFullName - ? ` [${params.targetRepositoryFullName}](https://github.com/${params.targetRepositoryFullName})` - : ''; - - const prefix = buildSuggestionBadgePrefix( - { - category: params.category, - priority: params.priority, - }, - { style: options.badgeStyle }, - ); - const text = [ - `**${prefix}${params.title}**${repoLabel}`, + `**${params.title}**`, params.brief, params.footerText?.trim() || null, ] @@ -90,31 +71,23 @@ export function buildSuggestionSlackText( return options.quote ? quoteSlackMarkdown(text) : text; } -export function buildSuggestionBadgePrefix( - params: { - category?: string | null; - priority?: string | null; - }, - options: { style?: SuggestionBadgeStyle } = {}, -): string { +export function buildSuggestionBadgePrefix(params: { + category?: string | null; + priority?: string | null; +}): string { const badges: string[] = []; - const badgeStyle = options.style ?? 'full'; if (params.priority && suggestionPrioritySet.has(params.priority)) { const priority = params.priority as SuggestionPriority; badges.push( - badgeStyle === 'color_only' - ? SUGGESTION_PRIORITY_EMOJIS[priority] - : `${SUGGESTION_PRIORITY_EMOJIS[priority]} [${SUGGESTION_PRIORITY_LABELS[priority]}]`, + `${SUGGESTION_PRIORITY_EMOJIS[priority]} [${SUGGESTION_PRIORITY_LABELS[priority]}]`, ); } if (params.category && suggestionCategorySet.has(params.category)) { const category = params.category as SuggestionCategory; badges.push( - badgeStyle === 'color_only' - ? `[${SUGGESTION_CATEGORY_LABELS[category]}]` - : `${SUGGESTION_CATEGORY_EMOJIS[category]} [${SUGGESTION_CATEGORY_LABELS[category]}]`, + `${SUGGESTION_CATEGORY_EMOJIS[category]} [${SUGGESTION_CATEGORY_LABELS[category]}]`, ); } diff --git a/apps/api/src/handlers/slack/helpers/suggestion-workspace.test.ts b/apps/api/src/handlers/slack/helpers/suggestion-workspace.test.ts index fe2233c4c..14c77044d 100644 --- a/apps/api/src/handlers/slack/helpers/suggestion-workspace.test.ts +++ b/apps/api/src/handlers/slack/helpers/suggestion-workspace.test.ts @@ -79,24 +79,18 @@ describe('buildSuggestionTaskPromptText', () => { }); describe('buildSuggestionSlackText', () => { - it('can render suggested-task Slack copy with only the priority color emoji and block quotes', () => { + it('renders suggested-task Slack copy with only the title and description', () => { const text = buildSuggestionSlackText( { title: 'Fix cron retries', brief: 'Fix cron retries', - category: 'bug', - priority: 'P0', - targetRepositoryFullName: 'acme/app', }, { - badgeStyle: 'color_only', quote: true, }, ); - expect(text).toBe( - '> **🔴 [Bug] Fix cron retries** [acme/app](https://github.com/acme/app)\n> Fix cron retries', - ); + expect(text).toBe('> **Fix cron retries**\n> Fix cron retries'); }); }); @@ -140,7 +134,6 @@ describe('shared scheduled suggestion Slack model helpers', () => { expect( getSharedScheduledSuggestionSlackTextOptions('suggested_tasks'), ).toEqual({ - badgeStyle: 'color_only', quote: true, }); expect( diff --git a/apps/api/src/handlers/tasks/__tests__/submitTaskSuggestions.test.ts b/apps/api/src/handlers/tasks/__tests__/submitTaskSuggestions.test.ts index ed83cb81b..150d4deff 100644 --- a/apps/api/src/handlers/tasks/__tests__/submitTaskSuggestions.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/submitTaskSuggestions.test.ts @@ -262,7 +262,11 @@ function requestSuggestions(app: Hono<{ Variables: Variables }>) { { title: 'Fix the parser', brief: 'Nil access is crashing the parser.', + category: 'bug', + priority: 'P1', + investigationContext: 'Parser crash path in apps/api.', targetRepositoryFullName: 'acme/app', + workspaceReadiness: 'bare_repo', }, ], }), @@ -285,7 +289,13 @@ function requestCurrentThreadSuggestions( { title: 'Fix the parser', brief: 'Nil access is crashing the parser.', - targetRepositoryFullName: 'acme/app', + category: 'bug', + priority: 'P0', + investigationContext: 'Legacy hidden context.', + targetRepositoryFullName: 'wrong/repository', + targetEnvironmentId: '10b031ec-b728-4d8f-a9a0-1ed4aa500511', + workspaceReadiness: 'environment_backed', + readinessMessage: 'Legacy readiness message.', }, ], }), @@ -373,8 +383,20 @@ describe('submitTaskSuggestions', () => { channelId: 'C123', metadata: { suggestionType: 'suggested_tasks', + launchRouting: 'router', }, }); + expect(insertedWorkItemValues[0]).toMatchObject({ + title: 'Fix the parser', + brief: 'Nil access is crashing the parser.', + category: null, + priority: null, + investigationContext: null, + targetRepositoryFullName: null, + targetEnvironmentId: null, + workspaceReadiness: null, + readinessMessage: null, + }); }); it('allows Slack app mention replies to attach suggestions', async () => { @@ -407,6 +429,62 @@ describe('submitTaskSuggestions', () => { }); }); + it('keeps current-thread scan suggestions pinned to verified metadata', async () => { + mockTaskRunFindFirst.mockResolvedValue({ + id: 1, + payloadKind: TaskPayloadKind.Scan, + actingUserId: 'user-1', + payload: { repo: 'acme/app', selectedRepositories: ['acme/app'] }, + }); + mockTaskFindFirst.mockResolvedValue({ + initiatorUserId: 'user-1', + initiatorAutomation: 'suggest_ideas', + slackChannelId: 'C123', + slackThreadTs: '111.222', + }); + const app = createApp({ + runId: 1, + userId: 'user-1', + principal: 'user', + tokenType: 'run', + version: 1, + }); + + const response = await app.request( + new Request('http://localhost/tasks/task-1/task_suggestions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + delivery: 'current_thread', + submissionKey: 'scan-reply', + suggestions: [ + { + title: 'Fix the parser', + brief: 'Nil access is crashing the parser.', + category: 'bug', + priority: 'P1', + investigationContext: 'Parser crash path in apps/api.', + targetRepositoryFullName: 'acme/app', + workspaceReadiness: 'bare_repo', + }, + ], + }), + }), + ); + + expect(response.status).toBe(200); + expect(insertedWorkItemValues[0]).toMatchObject({ + category: 'bug', + priority: 'P1', + investigationContext: 'Parser crash path in apps/api.', + targetRepositoryFullName: 'acme/app', + workspaceReadiness: 'bare_repo', + }); + expect(insertedTrackedMessageValues[0]?.metadata).not.toHaveProperty( + 'launchRouting', + ); + }); + it('resolves standard task repositories from the selected environment', async () => { mockEnvironmentFindFirst.mockResolvedValue({ config: { repositories: [{ repository: 'acme/app' }] }, @@ -561,6 +639,11 @@ describe('submitTaskSuggestions', () => { expect(insertedWorkItemValues[0]).toMatchObject({ kind: 'suggestion', automationKey: 'suggest_ideas', + category: 'bug', + priority: 'P1', + investigationContext: 'Parser crash path in apps/api.', + targetRepositoryFullName: 'acme/app', + workspaceReadiness: 'bare_repo', }); // Regression 1: a null poster does not suppress the Slack summary post 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 9b2338674..9b02ead86 100644 --- a/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts +++ b/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts @@ -28,9 +28,12 @@ type CurrentThreadSuggestionMessage = { messageId: string; }; -export async function findCurrentThreadSuggestionIdByMessage( +async function findCurrentThreadSuggestionCardByMessage( input: CurrentThreadSuggestionMessage, -): Promise { +): Promise<{ + workItemId: string | null; + metadata: Record | null; +} | null> { const channelCondition = input.surface === 'teams' ? sql`split_part(${trackedMessages.channelId}, ';messageid=', 1) = split_part(${input.channelId}, ';messageid=', 1)` @@ -42,16 +45,24 @@ export async function findCurrentThreadSuggestionIdByMessage( channelCondition, eq(trackedMessages.messageTs, input.messageId), ), - columns: { workItemId: true }, + columns: { workItemId: true, metadata: true }, }); + return trackedCard ?? null; +} + +export async function findCurrentThreadSuggestionIdByMessage( + input: CurrentThreadSuggestionMessage, +): Promise { + const trackedCard = await findCurrentThreadSuggestionCardByMessage(input); return trackedCard?.workItemId ?? null; } export async function claimCurrentThreadSuggestionByMessage( input: CurrentThreadSuggestionMessage, ): Promise { - const workItemId = await findCurrentThreadSuggestionIdByMessage(input); + const trackedCard = await findCurrentThreadSuggestionCardByMessage(input); + const workItemId = trackedCard?.workItemId; if (!workItemId) { return { outcome: 'no_card' }; @@ -62,15 +73,23 @@ export async function claimCurrentThreadSuggestionByMessage( return { outcome: 'already_started' }; } + // Cards marked launchRouting: 'router' are presentation-only chat-reply + // suggestions; drop their pinned launch metadata so the task router selects + // the workspace. Unmarked cards (scan and setup) keep their verified + // targets. + const routed = trackedCard.metadata?.launchRouting === 'router'; + return { outcome: 'claimed', suggestion: { id: claimed.id, title: claimed.title, brief: claimed.brief, - investigationContext: claimed.investigationContext, - targetRepositoryFullName: claimed.targetRepositoryFullName, - targetEnvironmentId: claimed.targetEnvironmentId, + investigationContext: routed ? null : claimed.investigationContext, + targetRepositoryFullName: routed + ? null + : claimed.targetRepositoryFullName, + targetEnvironmentId: routed ? null : claimed.targetEnvironmentId, launchClaimedAt: claimed.launchClaimedAt, }, }; diff --git a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts index c5af24212..4602f52f8 100644 --- a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts +++ b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts @@ -168,6 +168,7 @@ type TaskSuggestionType = type SuggestionCardMessageRow = { suggestionType: TaskSuggestionType; + launchRouting?: 'router'; messageTs: string; channelId: string; workItemId: string; @@ -195,6 +196,7 @@ function buildSlackSuggestionCardValues( metadata: { suggestionType: row.suggestionType, suggestionKey: row.suggestionKey, + ...(row.launchRouting ? { launchRouting: row.launchRouting } : {}), }, })); } @@ -602,6 +604,7 @@ async function postTaskSuggestionsThreadToSlack(params: { slackChannelId: string; createdByUserId: string | null; suggestionType: TaskSuggestionType; + launchRouting?: 'router'; rootText: string; existingRootMessageTs?: string; automationLabel?: string | null; @@ -698,12 +701,14 @@ async function postTaskSuggestionsThreadToSlack(params: { const targetEnvironmentName = suggestion.targetEnvironmentId ? (environmentNamesById.get(suggestion.targetEnvironmentId) ?? null) : null; - const footer = buildSuggestionSlackFooter({ - category: suggestion.category, - targetRepositoryFullName: suggestion.targetRepositoryFullName, - targetEnvironmentName, - automationLabel: params.automationLabel, - }); + const footer = useSharedSuggestionFormatting + ? null + : buildSuggestionSlackFooter({ + category: suggestion.category, + targetRepositoryFullName: suggestion.targetRepositoryFullName, + targetEnvironmentName, + automationLabel: params.automationLabel, + }); const footerContextBlock = footer ? [ { @@ -727,9 +732,6 @@ async function postTaskSuggestionsThreadToSlack(params: { { title: suggestion.title, brief: suggestion.brief, - category: suggestion.category, - priority: suggestion.priority, - targetRepositoryFullName: suggestion.targetRepositoryFullName, footerText: footer, }, sharedOptions, @@ -738,9 +740,6 @@ async function postTaskSuggestionsThreadToSlack(params: { { title: suggestion.title, brief: suggestion.brief, - category: suggestion.category, - priority: suggestion.priority, - targetRepositoryFullName: suggestion.targetRepositoryFullName, footerText: null, }, sharedOptions, @@ -772,6 +771,7 @@ async function postTaskSuggestionsThreadToSlack(params: { suggestionMessageRows.push({ suggestionType: params.suggestionType, + ...(params.launchRouting ? { launchRouting: params.launchRouting } : {}), messageTs, channelId: params.slackChannelId, workItemId: suggestion.id, @@ -803,6 +803,7 @@ async function postCurrentThreadSuggestionsToSlack(params: { slackChannelId: string; slackThreadTs: string; createdByUserId: string | null; + launchRouting?: 'router'; suggestions: PersistedTaskSuggestion[]; }): Promise { const missingSuggestions = await getMissingTrackedSuggestions( @@ -830,6 +831,7 @@ async function postCurrentThreadSuggestionsToSlack(params: { slackChannelId: params.slackChannelId, createdByUserId: params.createdByUserId, suggestionType: 'suggested_tasks', + launchRouting: params.launchRouting, rootText: '', existingRootMessageTs: params.slackThreadTs, suggestions: missingSuggestions, @@ -1239,6 +1241,8 @@ export async function submitTaskSuggestions( (run.payloadKind === TaskPayloadKind.StandardTask || run.payloadKind === TaskPayloadKind.Scan || run.payloadKind === TaskPayloadKind.SlackAppMention); + const usesRouterLaunchContract = + isCurrentThreadTask && run.payloadKind !== TaskPayloadKind.Scan; if (run.payloadKind !== TaskPayloadKind.Scan && !isCurrentThreadTask) { return c.json({ error: 'Task is not a Suggested Tasks task' }, 400); @@ -1317,18 +1321,15 @@ export async function submitTaskSuggestions( const repositoryIds = candidateRepositories.map( (repository) => repository.id, ); - const submittedSuggestions = - isCurrentThreadTask && payload.environmentId - ? parsedBody.data.suggestions.map((suggestion) => - suggestion.targetEnvironmentId - ? suggestion - : { - ...suggestion, - targetEnvironmentId: payload.environmentId, - workspaceReadiness: 'environment_backed' as const, - }, - ) - : parsedBody.data.suggestions; + // Chat-reply suggestions are presentation-only proposals. Ignore launch + // metadata from older workers so the task router chooses the workspace + // when a user starts one instead of trusting the proposing agent. + const submittedSuggestions = usesRouterLaunchContract + ? parsedBody.data.suggestions.map((suggestion) => ({ + title: suggestion.title, + brief: suggestion.brief, + })) + : parsedBody.data.suggestions; const preparedSuggestions = await resolvePreparedSuggestions({ suggestions: submittedSuggestions, candidateRepositories, @@ -1338,26 +1339,12 @@ export async function submitTaskSuggestions( const suggestions = isOnboardingTrigger ? preparedSuggestions : prioritizeScheduledSuggestions(preparedSuggestions); - const currentThreadSuggestionsMissingLaunchMetadata = isCurrentThreadTask - ? suggestions.filter( - (suggestion) => suggestion.targetRepositoryFullName === null, - ) - : []; - - if (currentThreadSuggestionsMissingLaunchMetadata.length > 0) { - return c.json( - { - error: - 'Current-thread suggestions must include targetRepositoryFullName.', - }, - 400, - ); - } - const suggestionsMissingLaunchMetadata = isOnboardingTrigger - ? [] - : suggestions.filter( - (suggestion) => suggestion.targetRepositoryFullName === null, - ); + const suggestionsMissingLaunchMetadata = + isOnboardingTrigger || usesRouterLaunchContract + ? [] + : suggestions.filter( + (suggestion) => suggestion.targetRepositoryFullName === null, + ); if (suggestionsMissingLaunchMetadata.length > 0) { apiLogger.warn( @@ -1367,11 +1354,12 @@ export async function submitTaskSuggestions( `[submitTaskSuggestions] Dropping ${suggestionsMissingLaunchMetadata.length} scheduled suggestions without per-idea launch metadata for taskId=${taskId}`, ); } - const suggestionsToPersist = isOnboardingTrigger - ? suggestions - : suggestions.filter( - (suggestion) => suggestion.targetRepositoryFullName !== null, - ); + const suggestionsToPersist = + isOnboardingTrigger || usesRouterLaunchContract + ? suggestions + : suggestions.filter( + (suggestion) => suggestion.targetRepositoryFullName !== null, + ); const persistedSuggestions = await db.transaction(async (tx) => { const workItemColumns = { @@ -1500,6 +1488,7 @@ export async function submitTaskSuggestions( slackChannelId: task.slackChannelId, slackThreadTs: task.slackThreadTs, createdByUserId, + launchRouting: usesRouterLaunchContract ? 'router' : undefined, suggestions: missingSuggestions, }) : communicationProvider === 'discord' && communicationChannel @@ -1507,6 +1496,9 @@ export async function submitTaskSuggestions( sourceTaskId: taskId, suggestionGroupKey: parsedBody.data.submissionKey ?? taskId, createdByUserId, + launchRouting: usesRouterLaunchContract + ? 'router' + : undefined, channelId: communicationChannel, threadId: communicationThread, suggestions: numberedMissingSuggestions, @@ -1516,6 +1508,9 @@ export async function submitTaskSuggestions( sourceTaskId: taskId, suggestionGroupKey: parsedBody.data.submissionKey ?? taskId, createdByUserId, + launchRouting: usesRouterLaunchContract + ? 'router' + : undefined, chatId: communicationChannel, threadId: communicationThread, suggestions: numberedMissingSuggestions, @@ -1530,6 +1525,9 @@ export async function submitTaskSuggestions( suggestionGroupKey: parsedBody.data.submissionKey ?? taskId, createdByUserId, + launchRouting: usesRouterLaunchContract + ? 'router' + : undefined, conversationId: communicationChannel, serviceUrl, threadId: communicationThread, diff --git a/apps/api/src/handlers/teams/automation-suggestions.ts b/apps/api/src/handlers/teams/automation-suggestions.ts index 9958c2cc8..bfd757d62 100644 --- a/apps/api/src/handlers/teams/automation-suggestions.ts +++ b/apps/api/src/handlers/teams/automation-suggestions.ts @@ -36,6 +36,7 @@ export async function postCurrentThreadSuggestionsToTeams(params: { sourceTaskId: string; suggestionGroupKey: string; createdByUserId: string | null; + launchRouting?: 'router'; conversationId: string; serviceUrl: string; threadId?: string | null; @@ -70,6 +71,9 @@ export async function postCurrentThreadSuggestionsToTeams(params: { suggestionType: 'suggested_tasks', suggestionKey: `${params.sourceTaskId}:${suggestion.id}`, suggestionGroupKey: params.suggestionGroupKey, + ...(params.launchRouting + ? { launchRouting: params.launchRouting } + : {}), }, }; await db 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 97fc664e7..c94d49663 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 @@ -25,6 +25,7 @@ describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { status?: 'open' | 'launching' | 'launched'; launchClaimedAt?: Date | null; channelId?: string; + launchRouting?: 'router'; }): Promise { const [row] = await db .insert(workItems) @@ -53,6 +54,9 @@ describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { metadata: { suggestionType: 'setup_onboarding', suggestionKey: `source-task:${workItemId}`, + ...(overrides?.launchRouting + ? { launchRouting: overrides.launchRouting } + : {}), }, }); @@ -115,6 +119,23 @@ describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { expect(second).toBeNull(); }); + it('discards pinned metadata for router-launched suggestion cards', async () => { + const workItemId = await seedSuggestionWorkItem({ + launchRouting: 'router', + }); + + const claimed = await claimTelegramSuggestionLaunch({ + suggestionId: workItemId, + chatId, + }); + + expect(claimed).toMatchObject({ + investigationContext: null, + targetRepositoryFullName: null, + targetEnvironmentId: null, + }); + }); + it('does not claim a launched work item', async () => { const workItemId = await seedSuggestionWorkItem({ status: 'launched' }); diff --git a/apps/api/src/handlers/telegram/automation-suggestions.ts b/apps/api/src/handlers/telegram/automation-suggestions.ts index 2bf9a80a4..aee47c40b 100644 --- a/apps/api/src/handlers/telegram/automation-suggestions.ts +++ b/apps/api/src/handlers/telegram/automation-suggestions.ts @@ -39,6 +39,7 @@ export async function postCurrentThreadSuggestionsToTelegram(params: { sourceTaskId: string; suggestionGroupKey: string; createdByUserId: string | null; + launchRouting?: 'router'; chatId: string; threadId?: string | null; suggestions: TelegramAutomationSuggestion[]; @@ -76,6 +77,9 @@ export async function postCurrentThreadSuggestionsToTelegram(params: { suggestionType: 'suggested_tasks', suggestionKey: `${params.sourceTaskId}:${suggestion.id}`, suggestionGroupKey: params.suggestionGroupKey, + ...(params.launchRouting + ? { launchRouting: params.launchRouting } + : {}), }, }; await db diff --git a/apps/api/src/handlers/telegram/setup-suggestions.ts b/apps/api/src/handlers/telegram/setup-suggestions.ts index 97a2f17c8..889ff9553 100644 --- a/apps/api/src/handlers/telegram/setup-suggestions.ts +++ b/apps/api/src/handlers/telegram/setup-suggestions.ts @@ -174,7 +174,7 @@ export async function claimTelegramSuggestionLaunch(input: { eq(trackedMessages.channelId, input.chatId), eq(trackedMessages.workItemId, input.suggestionId), ), - columns: { id: true }, + columns: { id: true, metadata: true }, }); if (!trackedCard) { @@ -187,13 +187,19 @@ export async function claimTelegramSuggestionLaunch(input: { return null; } + // Cards marked launchRouting: 'router' are presentation-only chat-reply + // suggestions; drop their pinned launch metadata so the task router selects + // the workspace. Unmarked cards (scan and setup) keep their verified + // targets. + const routed = trackedCard.metadata?.launchRouting === 'router'; + return { id: claimed.id, title: claimed.title, brief: claimed.brief, - investigationContext: claimed.investigationContext, - targetRepositoryFullName: claimed.targetRepositoryFullName, - targetEnvironmentId: claimed.targetEnvironmentId, + investigationContext: routed ? null : claimed.investigationContext, + targetRepositoryFullName: routed ? null : claimed.targetRepositoryFullName, + targetEnvironmentId: routed ? null : claimed.targetEnvironmentId, launchClaimedAt: claimed.launchClaimedAt, }; } diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/reply-to-slack-thread.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/reply-to-slack-thread.test.ts index b18e834b1..222888190 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/reply-to-slack-thread.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/reply-to-slack-thread.test.ts @@ -91,7 +91,6 @@ describe('handleReplyToSlackThread', () => { title: 'Add retry telemetry', brief: 'Instrument retry exhaustion so operators can diagnose failures.', - targetRepositoryFullName: 'acme/app', }, ]; @@ -158,7 +157,6 @@ describe('handleReplyToSlackThread', () => { { title: 'Add retry telemetry', brief: 'Instrument retry exhaustion.', - targetRepositoryFullName: 'acme/app', }, ], chatReplySurface, @@ -190,7 +188,6 @@ describe('handleReplyToSlackThread', () => { { title: 'Add retry telemetry', brief: 'Instrument retry exhaustion.', - targetRepositoryFullName: 'acme/app', }, ], chatReplySurface: 'Slack', @@ -219,7 +216,6 @@ describe('handleReplyToSlackThread', () => { { title: 'Add retry telemetry', brief: 'Instrument retry exhaustion.', - targetRepositoryFullName: 'acme/app', }, ], chatReplySurface: 'Slack', diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tasks-api-client.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tasks-api-client.test.ts index 33836d95b..9ae5be054 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tasks-api-client.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tasks-api-client.test.ts @@ -497,7 +497,7 @@ describe('launchTask', () => { describe('submitTaskSuggestions', () => { afterEach(() => vi.restoreAllMocks()); - it('should POST task suggestions including hidden investigation context', async () => { + it('should POST task suggestions with only user-visible fields', async () => { const mockResponse = { success: true, suggestionCount: 1, @@ -513,12 +513,6 @@ describe('submitTaskSuggestions', () => { { title: 'Fix cron retries', brief: 'Retry metadata is dropped when rebuilding the payload.', - priority: 'P1', - investigationContext: - 'apps/api/src/jobs/retry.ts:92 drops the persisted retry delay.', - targetRepositoryFullName: 'acme/app', - targetEnvironmentId: '10b031ec-b728-4d8f-a9a0-1ed4aa500511', - workspaceReadiness: 'environment_backed', }, ], }); @@ -538,12 +532,6 @@ describe('submitTaskSuggestions', () => { { title: 'Fix cron retries', brief: 'Retry metadata is dropped when rebuilding the payload.', - priority: 'P1', - investigationContext: - 'apps/api/src/jobs/retry.ts:92 drops the persisted retry delay.', - targetRepositoryFullName: 'acme/app', - targetEnvironmentId: '10b031ec-b728-4d8f-a9a0-1ed4aa500511', - workspaceReadiness: 'environment_backed', }, ], }); diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts index 5bb5e0da9..ceb4c626e 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts @@ -379,8 +379,14 @@ describe('roomote MCP tool descriptions', () => { expect(replyTool.config.inputSchema.questions).toBeUndefined(); expect(replyTool.config.inputSchema.suggestedNextSteps).toBeUndefined(); expect(getInputSchemaField(replyTool, 'suggestions').description).toBe( - 'Optional independent actions to post inside the originating Slack conversation (maximum 10). Use only for high-confidence tasks not explicitly identified in the conversation as already underway. Every suggestion must identify its target repository; include hidden investigation context when it will help the implementing agent.', - ); + 'Optional independent actions to post inside the originating Slack conversation (maximum 10). Use only for high-confidence tasks not explicitly identified in the conversation as already underway. Each suggestion contains only the title and description shown to users; Roomote routes the task when it is started.', + ); + const suggestionItem = ( + replyTool.config.inputSchema.suggestions as unknown as { + unwrap: () => { element: { shape: Record } }; + } + ).unwrap().element; + expect(Object.keys(suggestionItem.shape)).toEqual(['title', 'brief']); }); it('documents the Teams chat reply tool when Teams communication context exists', async () => { @@ -426,6 +432,35 @@ describe('roomote MCP tool descriptions', () => { ); }); + it('keeps the rich suggestion contract for scheduled scan workflows', async () => { + const { registeredTools } = await importRoomoteMcpServer({ + ROOMOTE_SLACK_CHANNEL: 'C123', + ROOMOTE_SLACK_THREAD_TS: '123.456', + ROOMOTE_TASK_TYPE: 'scan', + }); + const replyTool = getRegisteredTool(registeredTools, 'send_chat_reply'); + const suggestionItem = ( + replyTool.config.inputSchema.suggestions as unknown as { + unwrap: () => { element: { shape: Record } }; + } + ).unwrap().element; + + expect(Object.keys(suggestionItem.shape)).toEqual([ + 'title', + 'brief', + 'category', + 'priority', + 'investigationContext', + 'targetRepositoryFullName', + 'targetEnvironmentId', + 'workspaceReadiness', + 'readinessMessage', + ]); + expect(getInputSchemaField(replyTool, 'suggestions').description).toContain( + 'scheduled suggestion workflow must include its verified target repository', + ); + }); + it('documents the Telegram chat reply tool when Telegram communication context exists', async () => { const { registeredTools } = await importRoomoteMcpServer({ ROOMOTE_COMMUNICATION_PROVIDER: 'telegram', @@ -806,18 +841,6 @@ describe('roomote MCP tool descriptions', () => { expect(definitionField.description).toContain('YAML or JSON string'); }); - it('keeps investigation context on chat reply suggestions', () => { - const source = readFileSync( - path.resolve(thisDirPath, '../index.ts'), - 'utf8', - ); - - expect(source).toContain('investigationContext: z'); - expect(source).not.toContain( - "registerTaskSuggestionsTool('submit_task_suggestions')", - ); - }); - it('forwards issueNumber from manage_source_control tool params', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index 03fc0b9c0..b2354a831 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -1201,6 +1201,26 @@ roomoteMcpServer.registerTool( if (shouldRegisterSlackThreadReplyTool()) { const chatReplySurfaceLabel = getChatReplySurfaceLabel(); + const usesPinnedSuggestionContract = + process.env.ROOMOTE_TASK_TYPE === TaskPayloadKind.Scan; + const chatReplySuggestionSchema = usesPinnedSuggestionContract + ? z.object({ + title: z.string().min(1).max(140), + brief: z.string().min(1).max(2000), + category: z + .enum(['bug', 'security', 'chore', 'feature', 'improvement']) + .optional(), + priority: z.enum(['P0', 'P1', 'P2', 'P3']).optional(), + investigationContext: z.string().min(1).max(4000).optional(), + targetRepositoryFullName: z.string().min(1), + targetEnvironmentId: z.string().uuid().optional(), + workspaceReadiness: workspaceReadinessSchema.optional(), + readinessMessage: z.string().min(1).max(500).optional(), + }) + : z.object({ + title: z.string().min(1).max(140), + brief: z.string().min(1).max(2000), + }); const chatReplyMarkdownGuidance = chatReplySurfaceLabel === 'Slack' ? 'Supports the modern Slack Markdown contract from the Slack instructions. Use rich Markdown when it improves scanability. ' @@ -1255,26 +1275,14 @@ if (shouldRegisterSlackThreadReplyTool()) { 'Optional already-uploaded artifact IDs for images to attach.', ), suggestions: z - .array( - z.object({ - title: z.string().min(1).max(140), - brief: z.string().min(1).max(2000), - category: z - .enum(['bug', 'security', 'chore', 'feature', 'improvement']) - .optional(), - priority: z.enum(['P0', 'P1', 'P2', 'P3']).optional(), - investigationContext: z.string().min(1).max(4000).optional(), - targetRepositoryFullName: z.string().min(1), - targetEnvironmentId: z.string().uuid().optional(), - workspaceReadiness: workspaceReadinessSchema.optional(), - readinessMessage: z.string().min(1).max(500).optional(), - }), - ) + .array(chatReplySuggestionSchema) .min(1) .max(10) .optional() .describe( - `Optional independent actions to post inside the originating ${chatReplySurfaceLabel} conversation (maximum 10). Use only for high-confidence tasks not explicitly identified in the conversation as already underway. Every suggestion must identify its target repository; include hidden investigation context when it will help the implementing agent.`, + usesPinnedSuggestionContract + ? `Optional independent actions to post inside the originating ${chatReplySurfaceLabel} conversation (maximum 10). This scheduled suggestion workflow must include its verified target repository and may include implementation metadata used when the task is started.` + : `Optional independent actions to post inside the originating ${chatReplySurfaceLabel} conversation (maximum 10). Use only for high-confidence tasks not explicitly identified in the conversation as already underway. Each suggestion contains only the title and description shown to users; Roomote routes the task when it is started.`, ), }, annotations: {