From 3a6a5cc4793c99d4b14153125c0a7cd6cdfde2b1 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:23:08 +0000 Subject: [PATCH 1/3] feat: add resumable session stop commands --- .../handlers/discord/__tests__/index.test.ts | 47 +++++++ apps/api/src/handlers/discord/index.ts | 33 +++++ .../message-entry-unmentioned-routing.test.ts | 51 +++++++ .../handlers/slack/events/message-entry.ts | 46 ++++++ .../tasks/session-stop-command.test.ts | 131 ++++++++++++++++++ .../handlers/tasks/session-stop-command.ts | 96 +++++++++++++ .../handlers/teams/__tests__/index.test.ts | 48 +++++++ apps/api/src/handlers/teams/index.ts | 38 +++++ .../handlers/telegram/__tests__/index.test.ts | 46 ++++++ apps/api/src/handlers/telegram/index.ts | 48 ++++++- .../docs/providers/communications/discord.mdx | 5 +- .../communications/microsoft-teams.mdx | 5 + apps/docs/providers/communications/slack.mdx | 6 + .../providers/communications/telegram.mdx | 12 +- apps/web/src/trpc/commands/sessions/index.ts | 54 ++------ .../src/trpc/commands/sessions/stop.test.ts | 81 ++++------- .../src/__tests__/discord-event.test.ts | 19 +++ .../src/__tests__/discord-provider.test.ts | 6 + .../__tests__/mock-telegram-server.test.ts | 1 + .../src/__tests__/session-command.test.ts | 26 ++++ .../src/__tests__/telegram-provider.test.ts | 2 + .../src/__tests__/telegram-update.test.ts | 62 +++++++++ packages/communication/src/discord-event.ts | 4 +- .../communication/src/discord-provider.ts | 6 + packages/communication/src/index.ts | 1 + packages/communication/src/session-command.ts | 9 ++ .../communication/src/telegram-provider.ts | 2 + packages/communication/src/telegram-update.ts | 14 +- packages/sdk/src/server/index.ts | 1 + .../lib/fast-agent-provider-message.test.ts | 56 ++++++++ .../server/lib/fast-agent-provider-message.ts | 34 +++++ .../server/lib/stop-session-task-runs.test.ts | 124 +++++++++++++++++ .../src/server/lib/stop-session-task-runs.ts | 68 +++++++++ 33 files changed, 1075 insertions(+), 107 deletions(-) create mode 100644 apps/api/src/handlers/tasks/session-stop-command.test.ts create mode 100644 apps/api/src/handlers/tasks/session-stop-command.ts create mode 100644 packages/communication/src/__tests__/session-command.test.ts create mode 100644 packages/communication/src/session-command.ts create mode 100644 packages/sdk/src/server/lib/stop-session-task-runs.test.ts create mode 100644 packages/sdk/src/server/lib/stop-session-task-runs.ts diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index dc7e8763a6..c11ac1997c 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -58,6 +58,7 @@ const mocks = vi.hoisted(() => ({ callViaEmojiConfig: vi.fn(), appendAccountLinkHelpText: vi.fn(async (message: string) => message), startGoal: vi.fn(), + stopChatSessionTasks: vi.fn(), acquireFastTurnLock: vi.fn(), answerFast: vi.fn(), hasFastSession: vi.fn(), @@ -135,6 +136,9 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('../../tasks/continue-session-attention-reply', () => ({ continueSessionAttentionReply: vi.fn(async () => false), })); +vi.mock('../../tasks/session-stop-command.js', () => ({ + stopChatSessionTasks: mocks.stopChatSessionTasks, +})); vi.mock('@roomote/sdk/server/communication', () => ({ findActiveCommunicationTaskRun: mocks.findActiveRun, @@ -349,6 +353,11 @@ describe('Discord Gateway event handler', () => { launchResult: { id: 17, taskId: 'task-17' }, }); mocks.startGoal.mockResolvedValue({ success: true, goal: {} }); + mocks.stopChatSessionTasks.mockResolvedValue({ + kind: 'stopped', + stoppedCount: 2, + text: 'Stopped 2 active tasks. The work remains resumable; send another message here to continue.', + }); mocks.acquireFastTurnLock.mockResolvedValue( vi.fn().mockResolvedValue(undefined), ); @@ -2494,6 +2503,44 @@ describe('Discord Gateway event handler', () => { ); }); + it('uses /stop to soft-stop all linked Session tasks and preserve continuation', async () => { + const interaction = { + id: 'interaction-stop', + application_id: 'app-1', + type: 2, + token: 'interaction-token', + channel_id: 'dm-1', + user: { id: 'discord-user-1', username: 'matt' }, + data: { name: 'stop', type: 1 }, + }; + + const response = await postEvent( + envelope(interaction, 'INTERACTION_CREATE'), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + stopRequested: true, + outcome: 'stopped', + }); + expect(mocks.stopChatSessionTasks).toHaveBeenCalledWith({ + provider: 'discord', + workspaceId: 'dm', + channelId: 'dm-1', + conversationId: 'dm-1', + userId: 'roomote-user-1', + displayName: 'matt', + }); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Stopped 2 active tasks. The work remains resumable; send another message here to continue.', + ephemeral: true, + }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.startGoal).not.toHaveBeenCalled(); + }); + it('uses /goal to start a session goal', async () => { mocks.findActiveRun.mockResolvedValue({ id: 23, diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index b39300d710..f303429b88 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -50,6 +50,7 @@ import { import { apiLogger } from '../../logging.js'; import { continueSessionAttentionReply } from '../tasks/continue-session-attention-reply.js'; +import { stopChatSessionTasks } from '../tasks/session-stop-command.js'; import { getCallRoomoteViaEmojiConfiguration } from '../call-roomote-via-emoji.js'; import { buildCommunicationTaskThreadName } from '../tasks/communication-task-thread.js'; import { @@ -152,6 +153,7 @@ const DISCORD_HELP_MESSAGE = [ '**Available commands**', '`/new request:` — start a fresh task.', '`/goal objective:` — keep this session working toward an objective across multiple turns.', + '`/stop` — resumably stop every active task in this session; send another message here to continue later.', '`/link code:` — link this Discord account in a DM with me.', '`/help` — show this message.', '', @@ -582,6 +584,8 @@ async function processDiscordGatewayEvent( if (command && command.name !== 'new') { if (command.name === 'goal') { // Handled after resolving the current conversation and linked user. + } else if (command.name === 'stop') { + // Handled after resolving the linked sender and conversation below. } else if (command.name === 'skills') { const registrations = [ resolved.provider.registerCommands({ @@ -884,6 +888,35 @@ async function processDiscordGatewayEvent( userId: senderUserId, }); + if (command?.name === 'stop') { + const stopResult = await stopChatSessionTasks({ + provider: 'discord', + workspaceId: channel.guildId ?? 'dm', + channelId: metadata.communicationChannelId, + conversationId: getDiscordFastConversationId( + channel, + interaction?.id ?? event.eventId, + ), + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + ...(message?.message_reference?.message_id + ? { replyToMessageId: message.message_reference.message_id } + : {}), + userId: senderUserId, + displayName: sender.global_name ?? sender.username, + }); + await replyToDiscordEvent({ + provider: resolved.provider, + applicationId: resolved.applicationId, + channel, + interaction: interactionReplyContext(event), + text: stopResult.text, + ephemeral: true, + }); + return { ok: true, stopRequested: true, outcome: stopResult.kind }; + } + // Fast mode is unconditional for ordinary linked-human messages, including // reaction summons: a configured emoji synthesizes a bot mention that enters // the fast agent, matching Slack's call-roomote-via-emoji flow. diff --git a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts index d52c31c029..2316009710 100644 --- a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts +++ b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts @@ -15,6 +15,7 @@ const { recordInboundSlackConversationMessageMock, processFastAgentMessageMock, findSessionAttentionNotificationReplyMock, + stopChatSessionTasksMock, } = vi.hoisted(() => ({ fetchThreadMessagesMock: vi.fn(), hasPendingRoutingConfirmationMock: vi.fn(), @@ -30,6 +31,7 @@ const { recordInboundSlackConversationMessageMock: vi.fn(), processFastAgentMessageMock: vi.fn(), findSessionAttentionNotificationReplyMock: vi.fn(), + stopChatSessionTasksMock: vi.fn(), })); vi.mock('@roomote/cloud-agents/server/typesafe-judgment', () => ({ @@ -83,6 +85,10 @@ vi.mock('./fast-agent.js', () => ({ processFastAgentMessage: processFastAgentMessageMock, })); +vi.mock('../../tasks/session-stop-command.js', () => ({ + stopChatSessionTasks: stopChatSessionTasksMock, +})); + vi.mock('@roomote/redis', async (importOriginal) => ({ ...(await importOriginal()), getRedis: () => ({ @@ -175,8 +181,53 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { lookupSlackUserMappingMock.mockResolvedValue({ activeMapping: { userId: 'user-1', slackUserId: 'U111' }, }); + stopChatSessionTasksMock.mockResolvedValue({ + kind: 'stopped', + stoppedCount: 2, + text: 'Stopped 2 active tasks. The work remains resumable; send another message here to continue.', + }); }); + it('handles an explicit Slack stop request before normal Fast message routing', async () => { + const { handleMessageOrAppMentionEvent } = + await import('./message-entry.js'); + const event = { + type: 'app_mention', + channel: 'C123', + channel_type: 'channel', + ts: '101.000', + thread_ts: '100.000', + user: 'U111', + text: '<@UBOT> stop', + } as never; + const postMessage = vi.fn().mockResolvedValue(undefined); + const context = { + slackInstallation, + slack: { postMessage }, + teamId: 'T123', + } as never; + + await handleMessageOrAppMentionEvent({ event, context }); + + expect(stopChatSessionTasksMock).toHaveBeenCalledWith({ + provider: 'slack', + workspaceId: 'T123', + channelId: 'C123', + conversationId: '100.000', + threadId: '100.000', + userId: 'user-1', + }); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'C123', + thread_ts: '100.000', + text: 'Stopped 2 active tasks. The work remains resumable; send another message here to continue.', + }), + ); + expect(fetchThreadMessagesMock).not.toHaveBeenCalled(); + expect(recordInboundSlackConversationMessageMock).not.toHaveBeenCalled(); + }, 15_000); + it('preserves linked inbound history capture for a suppressed reply', async () => { const { recordSuppressedUnmentionedSlackThreadReply } = await import('./message-entry.js'); diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index 8ba6368d4e..8641bbedf1 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -33,10 +33,12 @@ import { type TaskInitiator, isDeploymentReadOnlyError, } from '@roomote/types'; +import { isStopSessionCommandText } from '@roomote/communication'; import { findSessionAttentionNotificationReply } from '@roomote/sdk/server'; import { apiLogger } from '../../../logging.js'; import { continueSessionAttentionReply } from '../../tasks/continue-session-attention-reply.js'; +import { stopChatSessionTasks } from '../../tasks/session-stop-command.js'; import { ROUTING_LOCK_TTL_SECONDS, SLACK_ROUTING_LOCK_PREFIX, @@ -1162,6 +1164,7 @@ async function handleSlackEntryEvent(params: { slackInstallation: SlackInstallation; slack: SlackNotifier; teamId: string; + stopSessionCommand?: boolean; skipThreadFollowupHandling?: boolean; threadTaskId?: string; peerConversationsEnabled?: boolean; @@ -1177,6 +1180,7 @@ async function handleSlackEntryEvent(params: { slackInstallation, slack, teamId, + stopSessionCommand = false, skipThreadFollowupHandling = false, threadTaskId, peerConversationsEnabled, @@ -1213,6 +1217,24 @@ async function handleSlackEntryEvent(params: { return; } + if (stopSessionCommand) { + const stopResult = await stopChatSessionTasks({ + provider: 'slack', + workspaceId: teamId, + channelId: event.channel, + conversationId: event.thread_ts || event.ts, + threadId: event.thread_ts || event.ts, + userId: userMapping.userId, + }); + await slack.postMessage({ + channel: event.channel, + thread_ts: event.thread_ts || event.ts, + text: stopResult.text, + blocks: [{ type: 'markdown', text: stopResult.text }], + }); + return; + } + const threadId = event.thread_ts || event.ts; const attentionResolution = event.thread_ts ? await findSessionAttentionNotificationReply({ @@ -1342,6 +1364,30 @@ export async function handleMessageOrAppMentionEvent(params: { }): Promise { const { event, context } = params; enrichSlackMessageEvent(event); + const isStopCommand = isStopSessionCommandText( + event.authoredText ?? event.text, + ); + const isHumanSlackMessage = + Boolean(event.user) && + !event.bot_id && + event.subtype !== 'bot_message' && + event.user !== context.slackInstallation.botUserId; + const isStopCommandAddressedToRoomote = + isHumanSlackMessage && + (event.type === 'app_mention' || + event.channel_type === 'im' || + event.channel_type === 'mpim' || + mentionsSlackBot(event, context.slackInstallation.botUserId)); + if (isStopCommand && isStopCommandAddressedToRoomote) { + await handleSlackEntryEvent({ + event, + slackInstallation: context.slackInstallation, + slack: context.slack, + teamId: context.teamId, + stopSessionCommand: true, + }); + return; + } const automatedAppMentionEvent = isRoutableAutomatedSlackAppMention( event, context.slackInstallation, diff --git a/apps/api/src/handlers/tasks/session-stop-command.test.ts b/apps/api/src/handlers/tasks/session-stop-command.test.ts new file mode 100644 index 0000000000..2913009439 --- /dev/null +++ b/apps/api/src/handlers/tasks/session-stop-command.test.ts @@ -0,0 +1,131 @@ +const mocks = vi.hoisted(() => ({ + findSession: vi.fn(), + stopSessionTaskRuns: vi.fn(), +})); + +vi.mock('@roomote/sdk/server', () => ({ + findFastAgentSessionForProviderConversation: mocks.findSession, + stopSessionTaskRuns: mocks.stopSessionTaskRuns, +})); + +import { stopChatSessionTasks } from './session-stop-command'; + +describe('stopChatSessionTasks', () => { + beforeEach(() => { + mocks.findSession.mockReset(); + mocks.stopSessionTaskRuns.mockReset(); + }); + + it('soft-stops the linked owner session and confirms later messages can resume it', async () => { + mocks.findSession.mockResolvedValue({ + id: 'session-1', + userId: 'user-1', + owner: { kind: 'user', userId: 'user-1' }, + }); + mocks.stopSessionTaskRuns.mockResolvedValue({ + success: true, + stoppedCount: 2, + }); + + await expect( + stopChatSessionTasks({ + provider: 'slack', + workspaceId: 'team-1', + channelId: 'channel-1', + threadId: 'thread-1', + conversationId: 'thread-1', + replyToMessageId: 'message-1', + userId: 'user-1', + displayName: 'Ada Lovelace', + }), + ).resolves.toEqual({ + kind: 'stopped', + stoppedCount: 2, + text: 'Stopped 2 active tasks. The work remains resumable; send another message here to continue.', + }); + expect(mocks.findSession).toHaveBeenCalledWith({ + provider: 'slack', + workspaceId: 'team-1', + channelId: 'channel-1', + threadId: 'thread-1', + conversationId: 'thread-1', + replyToMessageId: 'message-1', + userId: 'user-1', + }); + expect(mocks.stopSessionTaskRuns).toHaveBeenCalledWith({ + sessionId: 'session-1', + authUserId: 'user-1', + cancelledBy: { name: 'Ada Lovelace', source: 'slack' }, + }); + }); + + it('does not stop a missing, foreign, or non-user-owned session', async () => { + mocks.findSession + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 'session-foreign-owner', + userId: 'user-1', + owner: { kind: 'user', userId: 'other-user' }, + }) + .mockResolvedValueOnce({ + id: 'session-automation', + userId: 'user-1', + owner: { kind: 'automation', automationKey: 'daily-brief' }, + }); + + const input = { + provider: 'telegram' as const, + workspaceId: 'chat-1', + channelId: 'chat-1', + conversationId: 'chat-1:user:user-1', + userId: 'user-1', + }; + + await expect(stopChatSessionTasks(input)).resolves.toMatchObject({ + kind: 'unavailable', + }); + await expect(stopChatSessionTasks(input)).resolves.toMatchObject({ + kind: 'unavailable', + }); + await expect(stopChatSessionTasks(input)).resolves.toMatchObject({ + kind: 'unavailable', + }); + expect(mocks.stopSessionTaskRuns).not.toHaveBeenCalled(); + }); + + it('reports an empty session and partial failures without claiming every stop succeeded', async () => { + mocks.findSession.mockResolvedValue({ + id: 'session-1', + userId: 'user-1', + owner: { kind: 'user', userId: 'user-1' }, + }); + mocks.stopSessionTaskRuns + .mockResolvedValueOnce({ success: true, stoppedCount: 0 }) + .mockResolvedValueOnce({ + success: false, + stoppedCount: 1, + failedCount: 1, + }); + + const input = { + provider: 'discord' as const, + workspaceId: 'guild-1', + channelId: 'thread-1', + threadId: 'thread-1', + conversationId: 'thread-1', + userId: 'user-1', + }; + + await expect(stopChatSessionTasks(input)).resolves.toMatchObject({ + kind: 'stopped', + stoppedCount: 0, + text: 'There are no active tasks in this session to stop.', + }); + await expect(stopChatSessionTasks(input)).resolves.toMatchObject({ + kind: 'partial', + stoppedCount: 1, + failedCount: 1, + text: 'Stopped 1 active task, but 1 could not be stopped. The stopped tasks remain resumable; some work may still be running.', + }); + }); +}); diff --git a/apps/api/src/handlers/tasks/session-stop-command.ts b/apps/api/src/handlers/tasks/session-stop-command.ts new file mode 100644 index 0000000000..3104f1ae3f --- /dev/null +++ b/apps/api/src/handlers/tasks/session-stop-command.ts @@ -0,0 +1,96 @@ +import { + findFastAgentSessionForProviderConversation, + stopSessionTaskRuns, + type FastAgentReplyProvider, +} from '@roomote/sdk/server'; + +type ChatStopProvider = Extract< + FastAgentReplyProvider, + 'discord' | 'slack' | 'teams' | 'telegram' +>; + +export type StopChatSessionTasksInput = { + provider: ChatStopProvider; + workspaceId: string; + channelId: string; + conversationId: string; + threadId?: string; + replyToMessageId?: string; + userId: string; + displayName?: string | null; +}; + +export type StopChatSessionTasksResult = + | { kind: 'unavailable'; text: string } + | { kind: 'stopped'; stoppedCount: number; text: string } + | { + kind: 'partial'; + stoppedCount: number; + failedCount: number; + text: string; + }; + +function pluralizeTask(count: number): string { + return `${count} active task${count === 1 ? '' : 's'}`; +} + +export async function stopChatSessionTasks( + input: StopChatSessionTasksInput, +): Promise { + const session = await findFastAgentSessionForProviderConversation({ + provider: input.provider, + workspaceId: input.workspaceId, + channelId: input.channelId, + conversationId: input.conversationId, + ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.replyToMessageId + ? { replyToMessageId: input.replyToMessageId } + : {}), + userId: input.userId, + }); + + if ( + !session || + session.owner.kind !== 'user' || + session.owner.userId !== input.userId || + session.userId !== input.userId + ) { + return { + kind: 'unavailable', + text: "I couldn't find a Roomote session you can stop in this conversation.", + }; + } + + const result = await stopSessionTaskRuns({ + sessionId: session.id, + authUserId: input.userId, + cancelledBy: { + ...(input.displayName?.trim() ? { name: input.displayName.trim() } : {}), + source: input.provider, + }, + }); + + if (!result.success) { + const failedCount = result.failedCount ?? 0; + return { + kind: 'partial', + stoppedCount: result.stoppedCount, + failedCount, + text: `Stopped ${pluralizeTask(result.stoppedCount)}, but ${failedCount} could not be stopped. The stopped tasks remain resumable; some work may still be running.`, + }; + } + + if (result.stoppedCount === 0) { + return { + kind: 'stopped', + stoppedCount: 0, + text: 'There are no active tasks in this session to stop.', + }; + } + + return { + kind: 'stopped', + stoppedCount: result.stoppedCount, + text: `Stopped ${pluralizeTask(result.stoppedCount)}. The work remains resumable; send another message here to continue.`, + }; +} diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index 1f9e896f48..eb65d8c75e 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -65,6 +65,7 @@ const { isFastProviderMessageMock, finalizeWorkItemMock, releaseWorkItemMock, + stopChatSessionTasksMock, } = vi.hoisted(() => ({ authAccountsFindFirstMock: vi.fn(), authAccountsFindManyMock: vi.fn(), @@ -134,6 +135,7 @@ const { isFastProviderMessageMock: vi.fn(), finalizeWorkItemMock: vi.fn(), releaseWorkItemMock: vi.fn(), + stopChatSessionTasksMock: vi.fn(), })); vi.mock('@roomote/env', () => ({ @@ -331,6 +333,9 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('../../tasks/continue-session-attention-reply', () => ({ continueSessionAttentionReply: vi.fn(async () => false), })); +vi.mock('../../tasks/session-stop-command.js', () => ({ + stopChatSessionTasks: stopChatSessionTasksMock, +})); vi.mock('@roomote/cloud-agents/server', () => ({ buildFastAgentReactionExternalInputQuestion: vi.fn( @@ -413,6 +418,11 @@ function createJwtPayload(payload: Record) { describe('Teams webhook handler', () => { beforeEach(() => { vi.clearAllMocks(); + stopChatSessionTasksMock.mockResolvedValue({ + kind: 'stopped', + stoppedCount: 2, + text: 'Stopped 2 active tasks. The work remains resumable; send another message here to continue.', + }); continueFastReplyMock.mockResolvedValue(true); queueFastReplyMock.mockResolvedValue(true); findFastMessageSessionMock.mockResolvedValue(null); @@ -514,6 +524,44 @@ describe('Teams webhook handler', () => { ); }); + it('stops active Session tasks for a linked Teams command and keeps them resumable', async () => { + teamsUserMappingFindFirstMock.mockResolvedValue({ + userId: 'mapped-user-1', + }); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer valid-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ text: 'Roomote /stop' }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + stopRequested: true, + outcome: 'stopped', + }); + expect(stopChatSessionTasksMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'teams', + workspaceId: 'tenant-1', + userId: 'mapped-user-1', + replyToMessageId: 'activity-root', + }), + ); + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Stopped 2 active tasks. The work remains resumable; send another message here to continue.', + }), + ); + expect(queueCommunicationMessageOnceMock).not.toHaveBeenCalled(); + expect(queueFastReplyMock).not.toHaveBeenCalled(); + }); + it('rejects Teams webhooks without a valid Bot Framework JWT', async () => { verifyBotFrameworkJwtMock.mockRejectedValueOnce(new Error('missing JWT')); const response = await createApp().request('/teams', { diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index 0cbfcf4528..14efc8d44e 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -19,6 +19,7 @@ import { parseTeamsActivity, teamsActivityToQueuedCommunicationMessage, } from '@roomote/communication/teams-activity'; +import { isStopSessionCommandText } from '@roomote/communication'; import { queueCommunicationMessage, queueCommunicationMessageOnce, @@ -126,6 +127,7 @@ import { resolveSuggestionOriginSessionId, } from '../tasks/suggestion-launch.js'; import { continueSessionAttentionReply } from '../tasks/continue-session-attention-reply.js'; +import { stopChatSessionTasks } from '../tasks/session-stop-command.js'; import { shouldRouteUnmentionedTeamsThreadReplyToAgent } from './unmentioned-thread-reply.js'; const TEAMS_ACTIVITY_DEDUP_PREFIX = 'teams:activity:'; @@ -2223,6 +2225,42 @@ teams.post('/', async (c) => { const fastChannelId = getTeamsBaseConversationId( metadata.communicationChannelId, ); + if (isStopSessionCommandText(queuedMessage.text)) { + if (!mappedUserId || !tenantId) { + await postTeamsAccountLinkPrompt({ activity, metadata }); + return c.json({ + ok: true, + queued: false, + reason: 'account_link_required', + }); + } + + const stopResult = await stopChatSessionTasks({ + provider: 'teams', + workspaceId: tenantId, + channelId: fastChannelId, + conversationId: + metadata.communicationThreadId ?? metadata.communicationChannelId, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + ...(replyToMessageId ? { replyToMessageId } : {}), + userId: mappedUserId, + displayName: activity.from?.name, + }); + await postTeamsMessageBestEffort({ + conversationId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId, + serviceUrl: metadata.communicationServiceUrl, + text: stopResult.text, + }); + return c.json({ + ok: true, + stopRequested: true, + outcome: stopResult.kind, + }); + } + const attentionResolution = mappedUserId && tenantId ? await findSessionAttentionNotificationReply({ diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index cb0d59f12e..fe54946dfb 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -33,6 +33,7 @@ const { redisSetMock, setLatestInboundMessageIdMock, startFastSessionGoalMock, + stopChatSessionTasksMock, setTrustedRunActingUserMock, setTrustedRunActingUserOnSuccessMock, stopTaskRunMock, @@ -92,6 +93,7 @@ const { redisSetMock: vi.fn(), setLatestInboundMessageIdMock: vi.fn(), startFastSessionGoalMock: vi.fn(), + stopChatSessionTasksMock: vi.fn(), setTrustedRunActingUserMock: vi.fn(), setTrustedRunActingUserOnSuccessMock: vi.fn(), stopTaskRunMock: vi.fn(), @@ -345,6 +347,9 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('../../tasks/continue-session-attention-reply', () => ({ continueSessionAttentionReply: vi.fn(async () => false), })); +vi.mock('../../tasks/session-stop-command.js', () => ({ + stopChatSessionTasks: stopChatSessionTasksMock, +})); vi.mock('@roomote/communication/telegram-provider', () => ({ TelegramCommunicationProvider: vi.fn().mockImplementation(function () { @@ -503,6 +508,11 @@ describe('Telegram webhook handler', () => { async ({ operation }) => operation(), ); startFastSessionGoalMock.mockResolvedValue({ success: true, goal: {} }); + stopChatSessionTasksMock.mockResolvedValue({ + kind: 'stopped', + stoppedCount: 2, + text: 'Stopped 2 active tasks. The work remains resumable; send another message here to continue.', + }); authUsersFindFirstMock.mockResolvedValue(null); usersFindFirstMock.mockResolvedValue(null); taskRunsFindFirstMock.mockResolvedValue(null); @@ -1643,6 +1653,42 @@ describe('Telegram webhook handler', () => { expect(getFastSessionMock).not.toHaveBeenCalled(); }); + it('stops the linked Session tasks resumably without queueing the command', async () => { + mockTelegramLinkedSender(); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + text: '/stop', + entities: [{ type: 'bot_command', offset: 0, length: 5 }], + }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + ok: true, + stopRequested: true, + outcome: 'stopped', + }); + expect(stopChatSessionTasksMock).toHaveBeenCalledWith({ + provider: 'telegram', + workspaceId: '222', + channelId: '222', + conversationId: '222:user:launch-owner-1', + userId: 'launch-owner-1', + displayName: 'Ada Lovelace', + }); + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: '222', + replyToMessageId: '456', + text: 'Stopped 2 active tasks. The work remains resumable; send another message here to continue.', + }), + ); + expect(queueCommunicationMessageOnceMock).not.toHaveBeenCalled(); + expect(startFastSessionGoalMock).not.toHaveBeenCalled(); + }); + it('starts a session goal and acknowledges the objective literally', async () => { mockTelegramLinkedSender(); diff --git a/apps/api/src/handlers/telegram/index.ts b/apps/api/src/handlers/telegram/index.ts index 0c72354172..5d964f710a 100644 --- a/apps/api/src/handlers/telegram/index.ts +++ b/apps/api/src/handlers/telegram/index.ts @@ -22,6 +22,7 @@ import { getNewTelegramMessageReactions, getTelegramGoalCommand, getTelegramNewTaskCommand, + getTelegramStopCommand, isTelegramImplicitTopicCreatedMessage, isTelegramPrivateChat, isTelegramStartCommand, @@ -81,6 +82,7 @@ const TELEGRAM_COMMAND_HELP = [ '`/start` — show this welcome message.', '`/help` — show command help.', '`/new ` — start a fresh conversation instead of continuing the current one; when topics are available, it opens a new topic.', + '`/stop` — resumably stop this session’s active tasks; send another message here to continue later.', '`/goal ` — keep this session working toward an objective.', ].join('\n'); @@ -134,6 +136,7 @@ import { } from './webhook-gate.js'; import { appendAccountLinkHelpText } from '../account-link-help.js'; import { continueSessionAttentionReply } from '../tasks/continue-session-attention-reply.js'; +import { stopChatSessionTasks } from '../tasks/session-stop-command.js'; // Deep-link payload used by the group "link account" button: tapping // https://t.me/?start=link opens the bot's DM with "/start link". @@ -672,8 +675,11 @@ telegram.post('/', async (c) => { const goalCommand = getTelegramGoalCommand(update, { botUsername: botUsername ?? undefined, }); + const stopCommand = getTelegramStopCommand(update, { + botUsername: botUsername ?? undefined, + }); - if (!queuedMessage && !newTaskCommand && !goalCommand) { + if (!queuedMessage && !newTaskCommand && !goalCommand && !stopCommand) { return c.json({ ok: true, ignored: 'unsupported_update' }); } @@ -683,15 +689,49 @@ telegram.post('/', async (c) => { 'message_id' in message.reply_to_message ? message.reply_to_message.message_id : undefined; + const replyToMessageId = repliedToReportRootId + ? String(repliedToReportRootId) + : undefined; + + if (stopCommand) { + const stopResult = await stopChatSessionTasks({ + provider: 'telegram', + workspaceId: metadata.communicationChannelId, + channelId: metadata.communicationChannelId, + conversationId: `${metadata.communicationThreadId ?? (isTelegramPrivateChat(message) ? metadata.communicationChannelId : (metadata.communicationMessageId ?? update.update_id))}:user:${senderUserId}`, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + ...(replyToMessageId ? { replyToMessageId } : {}), + userId: senderUserId, + displayName: + [message.from?.first_name, message.from?.last_name] + .filter(Boolean) + .join(' ') + .trim() || + message.from?.username || + null, + }); + await postTelegramMessageBestEffort({ + chatId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId, + replyToMessageId: metadata.communicationMessageId, + text: stopResult.text, + textFormat: 'markdown', + }); + return c.json({ + ok: true, + stopRequested: true, + outcome: stopResult.kind, + }); + } + const repliedToAutomationReport = repliedToReportRootId ? await findTelegramAutomationReportRun({ chatId: metadata.communicationChannelId, messageId: String(repliedToReportRootId), }) : null; - const replyToMessageId = repliedToReportRootId - ? String(repliedToReportRootId) - : undefined; const hasMedia = Boolean( message.photo?.length || message.document || message.audio || message.voice, ); diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 7bb5c47935..5d7fdf6c15 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -24,7 +24,7 @@ does not require an inbound webhook or public callback URL. token, and save. Roomote reads the bot and application identity from the token and registers -the `/new`, `/goal`, `/link`, and `/help` commands automatically. You +the `/new`, `/goal`, `/stop`, `/link`, and `/help` commands automatically. You do not need to copy an application ID or bot name into Roomote. @@ -121,6 +121,9 @@ under **Settings > Automations**, the same way you would pick a Slack channel. DM, `/new` sends the request into the DM conversation - use `/goal objective:` to make the current session pursue an objective across multiple turns; it may delegate child tasks as needed +- use `/stop` in a Roomote session thread or DM to resumably stop all active + tasks linked to that session, including delegated tasks; send another message + to continue later - Discord DMs, mentions, attachments, and eligible thread replies from your linked account always enter a session, which answers directly or delegates repository work into tasks diff --git a/apps/docs/providers/communications/microsoft-teams.mdx b/apps/docs/providers/communications/microsoft-teams.mdx index 756c375c12..a1a6805b89 100644 --- a/apps/docs/providers/communications/microsoft-teams.mdx +++ b/apps/docs/providers/communications/microsoft-teams.mdx @@ -156,6 +156,11 @@ mention or eligible thread-reply signals used for task entry. Sessions are isolated by linked Roomote user even when several people share one Teams thread. Unlinked senders receive the account-link prompt instead of starting work. +In an existing session conversation, send `/stop` (or mention the bot with +`/stop` in a channel) to soft-stop every active task linked to that session, +including delegated tasks. The work remains resumable: send another message to +continue later. Only the linked session owner can stop its tasks. + Peer conversations do not apply to Teams. Bot Framework webhooks provide addressed activity, and delegated Microsoft Graph access can supply bounded history for eligible thread replies, but Roomote still keeps each person's diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 9e12e9243c..fed1993da9 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -232,6 +232,12 @@ for example `@Roomote summarize this thread` or `@Roomote fix the failing CI job`. Replies in a Roomote-owned Slack thread continue that session; replies in an existing task-only thread continue the task. +To stop work without discarding it, reply with `stop` in the existing session +thread; in a channel, mention the app with `stop`. Roomote soft-stops every +active task linked to that session, including delegated tasks; the work remains +resumable by sending another message. Only the linked session owner can stop its +tasks. + Roomote can read a bounded history from the current Slack channel, discover other channels in the same workspace, and post standalone updates to channels where the Roomote app is authorized. From another session surface, such as Telegram, you diff --git a/apps/docs/providers/communications/telegram.mdx b/apps/docs/providers/communications/telegram.mdx index be8c719013..4c1b424ccd 100644 --- a/apps/docs/providers/communications/telegram.mdx +++ b/apps/docs/providers/communications/telegram.mdx @@ -19,7 +19,7 @@ Use `` below for your stable public Roomote URL. Message `@BotFather` in Telegram and create or select a bot. In the Roomote UI (**Settings > Communications > Telegram**), enter the bot token, then save. Roomote reads the bot identity from Telegram, generates a webhook secret, -registers the Bot API webhook, and adds `/start`, `/help`, `/new`, and `/goal` +registers the Bot API webhook, and adds `/start`, `/help`, `/new`, `/stop`, and `/goal` to the bot's command menu for you. In BotFather, open **Bot Settings > Threaded Mode** and enable it. Roomote will @@ -108,8 +108,10 @@ up on tasks on their behalf. the same session continues 4. use `/new ` and confirm a fresh conversation opens in its own topic when Threaded Mode is enabled -5. use `/goal ` and confirm the acknowledgement repeats the objective -6. reply in the task topic to send a follow-up +5. use `/stop` in a Roomote conversation with active tasks and confirm they stop + without losing resumability +6. use `/goal ` and confirm the acknowledgement repeats the objective +7. reply in the task topic to send a follow-up Every message from a linked user enters a session: in private chats directly, and in groups from bot mentions. Roomote answers directly or delegates @@ -120,6 +122,10 @@ Roomote user. `/new` starts a fresh conversation instead of continuing the current one, opening a new topic when Telegram supports it; in a plain private chat the request joins that chat's conversation. `/goal` makes the session own and pursue the supplied objective; Roomote may delegate child tasks as needed. +Use `/stop` in a private chat or Roomote topic to soft-stop all active tasks +linked to the current session, including delegated tasks. In a group without a +topic, reply to a Roomote message with `/stop@botname`. The linked owner can +continue the same session later by sending another message. Peer conversations do not apply to Telegram. Telegram's privacy settings can prevent ambient group messages from reaching bots, and diff --git a/apps/web/src/trpc/commands/sessions/index.ts b/apps/web/src/trpc/commands/sessions/index.ts index e654f49ab3..4c52879605 100644 --- a/apps/web/src/trpc/commands/sessions/index.ts +++ b/apps/web/src/trpc/commands/sessions/index.ts @@ -35,7 +35,11 @@ import { markTaskStartParallelCountsEndedAtForTaskIds, } from '@roomote/db/server'; import { captureEvent } from '@roomote/telemetry/server'; -import { settleLiveTaskMessageOnExit, stopTaskRun } from '@roomote/sdk/server'; +import { + settleLiveTaskMessageOnExit, + stopSessionTaskRuns, + stopTaskRun, +} from '@roomote/sdk/server'; import type { UserAuthSuccess } from '@/types'; import { @@ -83,49 +87,11 @@ export async function stopSessionTasksCommand( return { success: false as const, stoppedCount: 0 }; } - const runs = await db - .select({ - id: taskRuns.id, - taskId: taskRuns.taskId, - payload: taskRuns.payload, - status: taskRuns.status, - sandboxServerUrl: taskRuns.sandboxServerUrl, - actingUserId: taskRuns.actingUserId, - }) - .from(taskRuns) - .innerJoin(sessionTasks, eq(sessionTasks.taskId, taskRuns.taskId)) - .where( - and( - eq(sessionTasks.sessionId, sessionId), - inArray(taskRuns.status, activeRunStatuses as readonly RunStatus[]), - ), - ); - - const results = await Promise.all( - runs.map(async (run) => { - const result = await stopTaskRun({ - run, - authUserId: auth.userId, - terminate: false, - cancelledBy: { name: auth.name ?? undefined, source: 'web' }, - }).catch(() => null); - if (result?.success) return true; - - const current = await db.query.taskRuns.findFirst({ - where: eq(taskRuns.id, run.id), - columns: { status: true }, - }); - return !current || isExitedRunStatus(current.status); - }), - ); - const stoppedCount = results.filter(Boolean).length; - return { - success: stoppedCount === runs.length, - stoppedCount, - ...(stoppedCount === runs.length - ? {} - : { failedCount: runs.length - stoppedCount }), - }; + return stopSessionTaskRuns({ + sessionId, + authUserId: auth.userId, + cancelledBy: { name: auth.name ?? undefined, source: 'web' }, + }); } export async function deleteSessionCommand( diff --git a/apps/web/src/trpc/commands/sessions/stop.test.ts b/apps/web/src/trpc/commands/sessions/stop.test.ts index 270ed37df3..d36e499a0a 100644 --- a/apps/web/src/trpc/commands/sessions/stop.test.ts +++ b/apps/web/src/trpc/commands/sessions/stop.test.ts @@ -1,19 +1,15 @@ -import { - db, - runFactory, - sessionFactory, - sessionTasks, - taskFactory, - userFactory, -} from '@roomote/db/server'; -import { RunStatus } from '@roomote/types'; +import { sessionFactory, userFactory } from '@roomote/db/server'; import type { UserAuthSuccess } from '@/types'; -const mocks = vi.hoisted(() => ({ stopTaskRun: vi.fn() })); +const mocks = vi.hoisted(() => ({ + stopSessionTaskRuns: vi.fn(), + stopTaskRun: vi.fn(), +})); vi.mock('@roomote/sdk/server', () => ({ settleLiveTaskMessageOnExit: vi.fn(), + stopSessionTaskRuns: mocks.stopSessionTaskRuns, stopTaskRun: mocks.stopTaskRun, })); vi.mock('@roomote/telemetry/server', () => ({ captureEvent: vi.fn() })); @@ -21,7 +17,10 @@ vi.mock('@roomote/telemetry/server', () => ({ captureEvent: vi.fn() })); import { stopSessionTasksCommand } from './index'; describe('stopSessionTasksCommand', () => { - beforeEach(() => mocks.stopTaskRun.mockReset()); + beforeEach(() => { + mocks.stopSessionTaskRuns.mockReset(); + mocks.stopTaskRun.mockReset(); + }); async function fixture() { const owner = await userFactory.create(); @@ -29,55 +28,31 @@ describe('stopSessionTasksCommand', () => { ownerKind: 'user', ownerUserId: owner.id, }); - const taskOne = await taskFactory.create({ initiatorUserId: owner.id }); - const taskTwo = await taskFactory.create({ initiatorUserId: owner.id }); - await db.insert(sessionTasks).values([ - { sessionId: session.id, taskId: taskOne.id, origin: 'direct_launch' }, - { sessionId: session.id, taskId: taskTwo.id, origin: 'fast_delegation' }, - ]); - const runOne = await runFactory.create({ - taskId: taskOne.id, - status: RunStatus.Running, - sandboxServerUrl: 'https://sandbox-one.example', - }); - const runTwo = await runFactory.create({ - taskId: taskTwo.id, - status: RunStatus.Idle, - sandboxServerUrl: 'https://sandbox-two.example', - }); const auth = { userId: owner.id, isAdmin: false, name: 'Session owner', } as UserAuthSuccess; - return { auth, session, runOne, runTwo }; + return { auth, session }; } - it('fans out resumable stops to every exact active run concurrently', async () => { - const { auth, session, runOne, runTwo } = await fixture(); - const stops = [ - Promise.withResolvers<{ success: true; mode: 'sandbox' }>(), - Promise.withResolvers<{ success: true; mode: 'sandbox' }>(), - ]; - mocks.stopTaskRun - .mockReturnValueOnce(stops[0]!.promise) - .mockReturnValueOnce(stops[1]!.promise); - - const stopping = stopSessionTasksCommand(auth, session.id); - await vi.waitFor(() => expect(mocks.stopTaskRun).toHaveBeenCalledTimes(2)); - expect( - mocks.stopTaskRun.mock.calls.map(([input]) => input.run.id).sort(), - ).toEqual([runOne.id, runTwo.id].sort((a, b) => a - b)); - for (const [input] of mocks.stopTaskRun.mock.calls) { - expect(input).toMatchObject({ - authUserId: auth.userId, - terminate: false, - cancelledBy: { name: auth.name, source: 'web' }, - }); - } + it('delegates an authorized session stop to the shared resumable stop path', async () => { + const { auth, session } = await fixture(); + mocks.stopSessionTaskRuns.mockResolvedValue({ + success: true, + stoppedCount: 2, + }); - stops.forEach((stop) => stop.resolve({ success: true, mode: 'sandbox' })); - await expect(stopping).resolves.toEqual({ success: true, stoppedCount: 2 }); + await expect(stopSessionTasksCommand(auth, session.id)).resolves.toEqual({ + success: true, + stoppedCount: 2, + }); + expect(mocks.stopSessionTaskRuns).toHaveBeenCalledWith({ + sessionId: session.id, + authUserId: auth.userId, + cancelledBy: { name: auth.name, source: 'web' }, + }); + expect(mocks.stopTaskRun).not.toHaveBeenCalled(); }); it('checks session management permission before stopping any run', async () => { @@ -87,6 +62,6 @@ describe('stopSessionTasksCommand', () => { await expect( stopSessionTasksCommand({ ...auth, userId: stranger.id }, session.id), ).resolves.toEqual({ success: false, stoppedCount: 0 }); - expect(mocks.stopTaskRun).not.toHaveBeenCalled(); + expect(mocks.stopSessionTaskRuns).not.toHaveBeenCalled(); }); }); diff --git a/packages/communication/src/__tests__/discord-event.test.ts b/packages/communication/src/__tests__/discord-event.test.ts index 72173571aa..461a5ac4fa 100644 --- a/packages/communication/src/__tests__/discord-event.test.ts +++ b/packages/communication/src/__tests__/discord-event.test.ts @@ -343,6 +343,25 @@ describe('Discord Gateway event normalization', () => { expect(discordEventToQueuedCommunicationMessage(event)).toBeNull(); }); + it('routes the /stop slash command as an explicit task-entry command', () => { + const event = parse({ + op: 0, + t: 'INTERACTION_CREATE', + d: { + id: 'interaction-stop', + application_id: 'application-1', + type: 2, + token: 'token', + channel_id: 'thread-1', + user: { id: 'user-1', username: 'matt' }, + data: { name: 'STOP', type: 1 }, + }, + }); + + expect(getDiscordInteractionCommand(event)).toEqual({ name: 'stop' }); + expect(isDiscordTaskEntryEvent(event)).toBe(true); + }); + it('does not treat removed fast interactions as task entry', () => { const event = parse({ op: 0, diff --git a/packages/communication/src/__tests__/discord-provider.test.ts b/packages/communication/src/__tests__/discord-provider.test.ts index b65c834d38..93d91d027d 100644 --- a/packages/communication/src/__tests__/discord-provider.test.ts +++ b/packages/communication/src/__tests__/discord-provider.test.ts @@ -305,6 +305,12 @@ describe('DiscordCommunicationProvider', () => { }), ], }, + { + name: 'stop', + type: 1, + description: + 'Stop active tasks in this session and keep them resumable', + }, { name: 'help', type: 1 }, ]); }); diff --git a/packages/communication/src/__tests__/mock-telegram-server.test.ts b/packages/communication/src/__tests__/mock-telegram-server.test.ts index 786db4c9e0..ff51a5fef3 100644 --- a/packages/communication/src/__tests__/mock-telegram-server.test.ts +++ b/packages/communication/src/__tests__/mock-telegram-server.test.ts @@ -86,6 +86,7 @@ describe('MockTelegramServer', () => { { command: 'start', description: 'Show welcome and command help' }, { command: 'help', description: 'Show command help' }, { command: 'new', description: 'Start a fresh task' }, + { command: 'stop', description: 'Stop this session’s active tasks' }, { command: 'goal', description: 'Keep working toward an objective', diff --git a/packages/communication/src/__tests__/session-command.test.ts b/packages/communication/src/__tests__/session-command.test.ts new file mode 100644 index 0000000000..0063432fce --- /dev/null +++ b/packages/communication/src/__tests__/session-command.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { isStopSessionCommandText } from '../session-command'; + +describe('isStopSessionCommandText', () => { + it.each(['/stop', 'stop', '/stop tasks', 'stop task'])( + 'recognizes the standalone command %s', + (text) => { + expect(isStopSessionCommandText(text)).toBe(true); + }, + ); + + it('allows a leading Slack or Teams bot mention', () => { + expect(isStopSessionCommandText('<@U123> /stop')).toBe(true); + expect(isStopSessionCommandText('Roomote stop tasks')).toBe(true); + }); + + it.each([ + 'please stop the build', + '/stop when you finish', + 'stop by the office', + 'Roomote please stop this task', + ])('does not classify ordinary text as a command: %s', (text) => { + expect(isStopSessionCommandText(text)).toBe(false); + }); +}); diff --git a/packages/communication/src/__tests__/telegram-provider.test.ts b/packages/communication/src/__tests__/telegram-provider.test.ts index 9f21bc21c5..6d1003cf4e 100644 --- a/packages/communication/src/__tests__/telegram-provider.test.ts +++ b/packages/communication/src/__tests__/telegram-provider.test.ts @@ -31,6 +31,7 @@ describe('TelegramCommunicationProvider', () => { { commands: [ { command: 'new', description: 'Start a fresh task' }, + { command: 'stop', description: 'Stop this session’s active tasks' }, { command: 'goal', description: 'Keep working toward an objective', @@ -43,6 +44,7 @@ describe('TelegramCommunicationProvider', () => { { command: 'start', description: 'Show welcome and command help' }, { command: 'help', description: 'Show command help' }, { command: 'new', description: 'Start a fresh task' }, + { command: 'stop', description: 'Stop this session’s active tasks' }, { command: 'goal', description: 'Keep working toward an objective', diff --git a/packages/communication/src/__tests__/telegram-update.test.ts b/packages/communication/src/__tests__/telegram-update.test.ts index b07dab6976..1795cbb889 100644 --- a/packages/communication/src/__tests__/telegram-update.test.ts +++ b/packages/communication/src/__tests__/telegram-update.test.ts @@ -4,6 +4,7 @@ import { getNewTelegramMessageReactions, getTelegramGoalCommand, getTelegramNewTaskCommand, + getTelegramStopCommand, getTelegramUpdateCallbackQuery, getTelegramUpdateCommunicationMetadata, getTelegramUpdateMessageReaction, @@ -886,4 +887,65 @@ describe('Telegram update helpers', () => { ).toBeNull(); }); }); + + describe('getTelegramStopCommand', () => { + const parse = ( + text: string, + chatType: 'private' | 'group' = 'private', + entities: Array<{ type: string; offset: number; length: number }> = [], + ) => + parseTelegramUpdate({ + update_id: 2003, + message: { + message_id: 44, + chat: { id: chatType === 'private' ? 6 : -1008, type: chatType }, + text, + entities, + }, + }).data!; + + it('recognizes a private command and only accepts group commands addressed to this bot', () => { + expect( + getTelegramStopCommand( + parse('/stop', 'private', [ + { type: 'bot_command', offset: 0, length: 5 }, + ]), + ), + ).toEqual({ command: 'stop' }); + expect( + getTelegramStopCommand( + parse('/stop@roomote_bot', 'group', [ + { type: 'bot_command', offset: 0, length: 17 }, + ]), + { botUsername: 'roomote_bot' }, + ), + ).toEqual({ command: 'stop' }); + expect( + getTelegramStopCommand( + parse('/stop', 'group', [ + { type: 'bot_command', offset: 0, length: 5 }, + ]), + { botUsername: 'roomote_bot' }, + ), + ).toBeNull(); + }); + + it('ignores commands for another bot and commands mentioned in ordinary text', () => { + expect( + getTelegramStopCommand( + parse('/stop@other_bot', 'group', [ + { type: 'bot_command', offset: 0, length: 15 }, + ]), + { botUsername: 'roomote_bot' }, + ), + ).toBeNull(); + expect( + getTelegramStopCommand( + parse('please /stop', 'private', [ + { type: 'bot_command', offset: 7, length: 5 }, + ]), + ), + ).toBeNull(); + }); + }); }); diff --git a/packages/communication/src/discord-event.ts b/packages/communication/src/discord-event.ts index c5d31c5e4e..58b6042ee4 100644 --- a/packages/communication/src/discord-event.ts +++ b/packages/communication/src/discord-event.ts @@ -586,7 +586,9 @@ export function isDiscordTaskEntryEvent( ); } const commandName = getDiscordInteractionCommand(event)?.name; - return commandName === 'new' || commandName === 'goal'; + return ( + commandName === 'new' || commandName === 'goal' || commandName === 'stop' + ); } function formatDiscordUser(input: { diff --git a/packages/communication/src/discord-provider.ts b/packages/communication/src/discord-provider.ts index 79b3703df2..347618df5a 100644 --- a/packages/communication/src/discord-provider.ts +++ b/packages/communication/src/discord-provider.ts @@ -1259,6 +1259,12 @@ export class DiscordCommunicationProvider implements CommunicationProviderAdapte }, ], }, + { + name: 'stop', + description: + 'Stop active tasks in this session and keep them resumable', + type: 1, + }, { name: 'help', description: 'Show Roomote command help', type: 1 }, ], { retryNetworkErrors: true, retryServerErrors: true }, diff --git a/packages/communication/src/index.ts b/packages/communication/src/index.ts index 49363a42ce..618ca3f081 100644 --- a/packages/communication/src/index.ts +++ b/packages/communication/src/index.ts @@ -11,6 +11,7 @@ export * from './messages'; export * from './provider'; export * from './reaction-emoji'; export * from './request-user-input'; +export * from './session-command'; export * from './task-thread-title'; export * from './teams-activity'; export * from './teams-bot-framework-client'; diff --git a/packages/communication/src/session-command.ts b/packages/communication/src/session-command.ts new file mode 100644 index 0000000000..99cc0427f7 --- /dev/null +++ b/packages/communication/src/session-command.ts @@ -0,0 +1,9 @@ +/** Match only a standalone stop command, optionally preceded by the bot mention + * included in Slack and Microsoft Teams message text. */ +export function isStopSessionCommandText(text: string): boolean { + const command = text + .trim() + .replace(/^(?:(?:<@[^>]+>|[^<]+<\/at>)\s*)+/iu, '') + .trim(); + return /^\/?stop(?:\s+tasks?)?$/iu.test(command); +} diff --git a/packages/communication/src/telegram-provider.ts b/packages/communication/src/telegram-provider.ts index f6a090a8e6..361700379b 100644 --- a/packages/communication/src/telegram-provider.ts +++ b/packages/communication/src/telegram-provider.ts @@ -535,6 +535,7 @@ export class TelegramCommunicationProvider implements CommunicationProviderAdapt await this.callBotApi('setMyCommands', { commands: [ { command: 'new', description: 'Start a fresh task' }, + { command: 'stop', description: 'Stop this session’s active tasks' }, { command: 'goal', description: 'Keep working toward an objective', @@ -547,6 +548,7 @@ export class TelegramCommunicationProvider implements CommunicationProviderAdapt { command: 'start', description: 'Show welcome and command help' }, { command: 'help', description: 'Show command help' }, { command: 'new', description: 'Start a fresh task' }, + { command: 'stop', description: 'Stop this session’s active tasks' }, { command: 'goal', description: 'Keep working toward an objective', diff --git a/packages/communication/src/telegram-update.ts b/packages/communication/src/telegram-update.ts index 91af3286f0..002cd0601c 100644 --- a/packages/communication/src/telegram-update.ts +++ b/packages/communication/src/telegram-update.ts @@ -620,6 +620,10 @@ export type TelegramGoalCommand = { objective: string; }; +export type TelegramStopCommand = { + command: 'stop'; +}; + /** * Detects an explicit `/new` command and returns the command name plus the task * description with the invocation stripped. Returns `null` for other updates. @@ -651,7 +655,15 @@ export function getTelegramGoalCommand( return command ? { command: 'goal', objective: command.argument } : null; } -function getTelegramLeadingCommand( +export function getTelegramStopCommand( + update: TelegramUpdate, + options: TelegramBotMentionOptions = {}, +): TelegramStopCommand | null { + const command = getTelegramLeadingCommand(update, 'stop', options); + return command ? { command: 'stop' } : null; +} + +function getTelegramLeadingCommand( update: TelegramUpdate, commandName: T, options: TelegramBotMentionOptions, diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 72c26ac0c0..3f97e5fa32 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -79,6 +79,7 @@ export { } from './lib/task-runs/record-task-inference-usage'; export { findTaskRunByRunTokenClaims } from './lib/task-runs/find-task-run'; export { stopTaskRun } from './lib/task-runs/stop-task-run'; +export { stopSessionTaskRuns } from './lib/stop-session-task-runs'; export { settleLiveTaskMessageOnExit } from './lib/task-runs/settle-live-task-message-on-exit'; export { createSnapshot } from './lib/task-runs/enqueue-snapshot'; export { diff --git a/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts b/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts index 8d6983dcb7..6051486bfc 100644 --- a/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { db, fastAgentConversations, userFactory } from '@roomote/db/server'; import { + findFastAgentSessionForProviderConversation, findFastAgentSessionForProviderMessage, findFastAgentSessionForProviderReply, isFastAgentProviderMessage, @@ -32,6 +33,50 @@ async function createFastConversation(input: { } describe('Fast provider message bindings', () => { + it('resolves a stop-command route to its linked owner and exact conversation', async () => { + const suffix = crypto.randomUUID(); + const { user, conversation } = await createFastConversation({ + surface: 'slack', + workspaceId: `team:${suffix}`, + conversationId: `thread:${suffix}`, + channelId: `channel:${suffix}`, + threadId: `thread:${suffix}`, + }); + + await expect( + findFastAgentSessionForProviderConversation({ + provider: 'slack', + workspaceId: `team:${suffix}`, + channelId: `channel:${suffix}`, + threadId: `thread:${suffix}`, + conversationId: `thread:${suffix}`, + userId: user.id, + }), + ).resolves.toMatchObject({ id: conversation.id, userId: user.id }); + + await expect( + findFastAgentSessionForProviderConversation({ + provider: 'slack', + workspaceId: `team:${suffix}`, + channelId: `other-channel:${suffix}`, + threadId: `thread:${suffix}`, + conversationId: `thread:${suffix}`, + userId: user.id, + }), + ).resolves.toBeNull(); + + await expect( + findFastAgentSessionForProviderConversation({ + provider: 'slack', + workspaceId: `team:${suffix}`, + channelId: `channel:${suffix}`, + threadId: `thread:${suffix}`, + conversationId: `thread:${suffix}`, + userId: 'another-user', + }), + ).resolves.toBeNull(); + }); + it('resolves an explicitly owned cross-surface notification reply', async () => { const suffix = crypto.randomUUID(); const user = await userFactory.create(); @@ -70,6 +115,17 @@ describe('Fast provider message bindings', () => { userId: 'another-user', }), ).resolves.toBeNull(); + + await expect( + findFastAgentSessionForProviderConversation({ + provider: 'slack', + workspaceId: `team:${suffix}`, + channelId: `dm:${suffix}`, + conversationId: `command:${suffix}`, + replyToMessageId: `message:${suffix}`, + userId: user.id, + }), + ).resolves.toMatchObject({ id: conversation!.id, userId: user.id }); }); it('resolves a Slack reaction target to its bound session owner', async () => { diff --git a/packages/sdk/src/server/lib/fast-agent-provider-message.ts b/packages/sdk/src/server/lib/fast-agent-provider-message.ts index de8c6aad87..e2e4284051 100644 --- a/packages/sdk/src/server/lib/fast-agent-provider-message.ts +++ b/packages/sdk/src/server/lib/fast-agent-provider-message.ts @@ -161,6 +161,40 @@ export async function findFastAgentSessionForProviderReply( : null; } +/** + * Find the owner-bound Fast session for an explicit chat command. Prefer the + * reply/thread binding (which also supports sessions started on another + * surface), then fall back to the provider's stable conversation identity for + * private chats and other routes without a thread root. + */ +export async function findFastAgentSessionForProviderConversation( + input: ProviderRoute & { + conversationId: string; + userId: string; + replyToMessageId?: string; + }, +): Promise { + const routed = await findFastAgentSessionForProviderReply(input); + if (routed) return routed; + + const conversation: FastAgentConversation = { + surface: input.provider, + workspaceId: input.workspaceId, + conversationId: input.conversationId, + replyTarget: { + channelId: input.channelId, + ...(input.threadId ? { threadId: input.threadId } : {}), + }, + }; + const session = + await fastAgentConversationRepository.findByConversation(conversation); + + return session?.userId === input.userId && + matchesProviderRoute(session, input) + ? session + : null; +} + export async function findFastAgentSessionForProviderMessage( input: ProviderRoute & { messageId: string; userId?: string }, ): Promise { diff --git a/packages/sdk/src/server/lib/stop-session-task-runs.test.ts b/packages/sdk/src/server/lib/stop-session-task-runs.test.ts new file mode 100644 index 0000000000..d6eff4c498 --- /dev/null +++ b/packages/sdk/src/server/lib/stop-session-task-runs.test.ts @@ -0,0 +1,124 @@ +import { + db, + runFactory, + sessionFactory, + sessionTasks, + taskFactory, + userFactory, +} from '@roomote/db/server'; +import { RunStatus } from '@roomote/types'; + +const mocks = vi.hoisted(() => ({ stopTaskRun: vi.fn() })); + +vi.mock('./task-runs/stop-task-run', () => ({ + stopTaskRun: mocks.stopTaskRun, +})); + +import { stopSessionTaskRuns } from './stop-session-task-runs'; + +describe('stopSessionTaskRuns', () => { + beforeEach(() => mocks.stopTaskRun.mockReset()); + + it('stops every active run linked to the Session without terminating or touching other sessions', async () => { + const owner = await userFactory.create(); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + }); + const linkedTaskIds = await Promise.all([ + taskFactory.create({ initiatorUserId: owner.id }), + taskFactory.create({ initiatorUserId: owner.id }), + ]); + const unrelatedTask = await taskFactory.create({ + initiatorUserId: owner.id, + }); + const completedTask = await taskFactory.create({ + initiatorUserId: owner.id, + }); + const unrelatedSession = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + }); + await db.insert(sessionTasks).values([ + { + sessionId: session.id, + taskId: linkedTaskIds[0]!.id, + origin: 'direct_launch', + }, + { + sessionId: session.id, + taskId: linkedTaskIds[1]!.id, + origin: 'fast_delegation', + }, + { + sessionId: unrelatedSession.id, + taskId: unrelatedTask.id, + origin: 'direct_launch', + }, + { + sessionId: session.id, + taskId: completedTask.id, + origin: 'direct_launch', + }, + ]); + + const running = await runFactory.create({ + taskId: linkedTaskIds[0]!.id, + status: RunStatus.Running, + sandboxServerUrl: 'https://sandbox-running.example', + }); + const idle = await runFactory.create({ + taskId: linkedTaskIds[1]!.id, + status: RunStatus.Idle, + sandboxServerUrl: 'https://sandbox-idle.example', + }); + await runFactory.create({ + taskId: unrelatedTask.id, + status: RunStatus.Running, + sandboxServerUrl: 'https://sandbox-unrelated.example', + }); + await runFactory.create({ + taskId: completedTask.id, + status: RunStatus.Completed, + sandboxServerUrl: 'https://sandbox-completed.example', + }); + mocks.stopTaskRun.mockResolvedValue({ + success: true, + mode: 'sandbox_stop', + }); + + await expect( + stopSessionTaskRuns({ + sessionId: session.id, + authUserId: owner.id, + cancelledBy: { name: 'Session owner', source: 'telegram' }, + }), + ).resolves.toEqual({ success: true, stoppedCount: 2 }); + + expect( + mocks.stopTaskRun.mock.calls + .map(([input]) => input.run.id) + .sort((a, b) => a - b), + ).toEqual([running.id, idle.id].sort((a, b) => a - b)); + for (const [input] of mocks.stopTaskRun.mock.calls) { + expect(input).toMatchObject({ + authUserId: owner.id, + terminate: false, + cancelledBy: { name: 'Session owner', source: 'telegram' }, + }); + } + }); + + it('succeeds when the Session has no active task runs', async () => { + const owner = await userFactory.create(); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + }); + + await expect( + stopSessionTaskRuns({ sessionId: session.id, authUserId: owner.id }), + ).resolves.toEqual({ success: true, stoppedCount: 0 }); + expect(mocks.stopTaskRun).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/server/lib/stop-session-task-runs.ts b/packages/sdk/src/server/lib/stop-session-task-runs.ts new file mode 100644 index 0000000000..100132490a --- /dev/null +++ b/packages/sdk/src/server/lib/stop-session-task-runs.ts @@ -0,0 +1,68 @@ +import { + and, + db, + eq, + inArray, + sessionTasks, + taskRuns, +} from '@roomote/db/server'; +import { + activeRunStatuses, + isExitedRunStatus, + type RunStatus, +} from '@roomote/types'; + +import { stopTaskRun } from './task-runs/stop-task-run'; + +/** Stop every currently active task linked to one Session without terminating + * its sandbox, so the task can be resumed from the same Session later. */ +export async function stopSessionTaskRuns(input: { + sessionId: string; + authUserId: string; + cancelledBy?: { name?: string; source?: string }; +}) { + const runs = await db + .select({ + id: taskRuns.id, + taskId: taskRuns.taskId, + payload: taskRuns.payload, + status: taskRuns.status, + sandboxServerUrl: taskRuns.sandboxServerUrl, + actingUserId: taskRuns.actingUserId, + }) + .from(taskRuns) + .innerJoin(sessionTasks, eq(sessionTasks.taskId, taskRuns.taskId)) + .where( + and( + eq(sessionTasks.sessionId, input.sessionId), + inArray(taskRuns.status, activeRunStatuses as readonly RunStatus[]), + ), + ); + + const results = await Promise.all( + runs.map(async (run) => { + const result = await stopTaskRun({ + run, + authUserId: input.authUserId, + terminate: false, + cancelledBy: input.cancelledBy, + }).catch(() => null); + if (result?.success) return true; + + const current = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.id, run.id), + columns: { status: true }, + }); + return !current || isExitedRunStatus(current.status); + }), + ); + + const stoppedCount = results.filter(Boolean).length; + return { + success: stoppedCount === runs.length, + stoppedCount, + ...(stoppedCount === runs.length + ? {} + : { failedCount: runs.length - stoppedCount }), + }; +} From 38298d85bb5467a2c9cae16385743986509a905c Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:26:30 +0000 Subject: [PATCH 2/3] fix: keep session stop handler types internal --- apps/api/src/handlers/tasks/session-stop-command.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/handlers/tasks/session-stop-command.ts b/apps/api/src/handlers/tasks/session-stop-command.ts index 3104f1ae3f..f815dfbb5b 100644 --- a/apps/api/src/handlers/tasks/session-stop-command.ts +++ b/apps/api/src/handlers/tasks/session-stop-command.ts @@ -9,7 +9,7 @@ type ChatStopProvider = Extract< 'discord' | 'slack' | 'teams' | 'telegram' >; -export type StopChatSessionTasksInput = { +type StopChatSessionTasksInput = { provider: ChatStopProvider; workspaceId: string; channelId: string; @@ -20,7 +20,7 @@ export type StopChatSessionTasksInput = { displayName?: string | null; }; -export type StopChatSessionTasksResult = +type StopChatSessionTasksResult = | { kind: 'unavailable'; text: string } | { kind: 'stopped'; stoppedCount: number; text: string } | { From 9d1ae2559bc393f280c96694e74422e76a66db1c Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:32:42 +0000 Subject: [PATCH 3/3] fix: route bare session stops in Slack threads --- .../message-entry-unmentioned-routing.test.ts | 70 +++++++++++++++++++ .../handlers/slack/events/message-entry.ts | 21 +++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts index 2316009710..935736403f 100644 --- a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts +++ b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts @@ -215,6 +215,7 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { channelId: 'C123', conversationId: '100.000', threadId: '100.000', + replyToMessageId: '100.000', userId: 'user-1', }); expect(postMessage).toHaveBeenCalledWith( @@ -228,6 +229,75 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { expect(recordInboundSlackConversationMessageMock).not.toHaveBeenCalled(); }, 15_000); + it('handles a bare stop reply in an existing session thread', async () => { + const { handleMessageOrAppMentionEvent } = + await import('./message-entry.js'); + const event = { + type: 'message', + channel: 'C123', + channel_type: 'channel', + ts: '102.000', + thread_ts: '100.000', + user: 'U111', + text: 'stop', + } as never; + const postMessage = vi.fn().mockResolvedValue(undefined); + + await handleMessageOrAppMentionEvent({ + event, + context: { + slackInstallation, + slack: { postMessage }, + teamId: 'T123', + } as never, + }); + + expect(stopChatSessionTasksMock).toHaveBeenCalledWith({ + provider: 'slack', + workspaceId: 'T123', + channelId: 'C123', + conversationId: '100.000', + threadId: '100.000', + replyToMessageId: '100.000', + userId: 'user-1', + }); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ thread_ts: '100.000' }), + ); + expect(fetchThreadMessagesMock).not.toHaveBeenCalled(); + }, 15_000); + + it('silently ignores a bare stop outside a stoppable session thread', async () => { + stopChatSessionTasksMock.mockResolvedValueOnce({ + kind: 'unavailable', + text: "I couldn't find a Roomote session you can stop in this conversation.", + }); + const { handleMessageOrAppMentionEvent } = + await import('./message-entry.js'); + const event = { + type: 'message', + channel: 'C123', + channel_type: 'channel', + ts: '102.000', + thread_ts: '100.000', + user: 'U111', + text: 'stop', + } as never; + const postMessage = vi.fn().mockResolvedValue(undefined); + + await handleMessageOrAppMentionEvent({ + event, + context: { + slackInstallation, + slack: { postMessage }, + teamId: 'T123', + } as never, + }); + + expect(stopChatSessionTasksMock).toHaveBeenCalledOnce(); + expect(postMessage).not.toHaveBeenCalled(); + }, 15_000); + it('preserves linked inbound history capture for a suppressed reply', async () => { const { recordSuppressedUnmentionedSlackThreadReply } = await import('./message-entry.js'); diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index 8641bbedf1..14faee1876 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -1165,6 +1165,7 @@ async function handleSlackEntryEvent(params: { slack: SlackNotifier; teamId: string; stopSessionCommand?: boolean; + silentStopWhenUnavailable?: boolean; skipThreadFollowupHandling?: boolean; threadTaskId?: string; peerConversationsEnabled?: boolean; @@ -1181,6 +1182,7 @@ async function handleSlackEntryEvent(params: { slack, teamId, stopSessionCommand = false, + silentStopWhenUnavailable = false, skipThreadFollowupHandling = false, threadTaskId, peerConversationsEnabled, @@ -1224,8 +1226,12 @@ async function handleSlackEntryEvent(params: { channelId: event.channel, conversationId: event.thread_ts || event.ts, threadId: event.thread_ts || event.ts, + ...(event.thread_ts ? { replyToMessageId: event.thread_ts } : {}), userId: userMapping.userId, }); + if (silentStopWhenUnavailable && stopResult.kind === 'unavailable') { + return; + } await slack.postMessage({ channel: event.channel, thread_ts: event.thread_ts || event.ts, @@ -1370,7 +1376,7 @@ export async function handleMessageOrAppMentionEvent(params: { const isHumanSlackMessage = Boolean(event.user) && !event.bot_id && - event.subtype !== 'bot_message' && + !event.subtype && event.user !== context.slackInstallation.botUserId; const isStopCommandAddressedToRoomote = isHumanSlackMessage && @@ -1378,13 +1384,24 @@ export async function handleMessageOrAppMentionEvent(params: { event.channel_type === 'im' || event.channel_type === 'mpim' || mentionsSlackBot(event, context.slackInstallation.botUserId)); - if (isStopCommand && isStopCommandAddressedToRoomote) { + const isUnmentionedStopInSessionThread = + isHumanSlackMessage && + event.type === 'message' && + event.channel_type !== 'im' && + event.channel_type !== 'mpim' && + Boolean(event.thread_ts) && + !mentionsSlackBot(event, context.slackInstallation.botUserId); + if ( + isStopCommand && + (isStopCommandAddressedToRoomote || isUnmentionedStopInSessionThread) + ) { await handleSlackEntryEvent({ event, slackInstallation: context.slackInstallation, slack: context.slack, teamId: context.teamId, stopSessionCommand: true, + silentStopWhenUnavailable: isUnmentionedStopInSessionThread, }); return; }