diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 81fd2985e..e9d9f8f21 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -51,6 +51,7 @@ const mocks = vi.hoisted(() => ({ buildContinuation: vi.fn(), releaseContinuation: vi.fn(), markThreadHistoryDelivered: vi.fn(), + activateGoal: vi.fn(), fetchThreadHistory: vi.fn(), shouldRouteUnmentioned: vi.fn(), enqueueGatewayEvent: vi.fn(), @@ -132,6 +133,13 @@ vi.mock('@roomote/communication/messages', () => ({ setLatestInboundMessageId: mocks.setLatestInbound, })); +vi.mock('@roomote/communication/task-goal', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@roomote/communication/task-goal') + >()), + activateTaskGoal: mocks.activateGoal, +})); + vi.mock('../../tasks/acting-user-sync.js', () => ({ syncActingUserForInboundMessage: mocks.syncActingUser, })); @@ -313,6 +321,27 @@ describe('Discord Gateway event handler', () => { mocks.fetchThreadHistory.mockResolvedValue([]); mocks.shouldRouteUnmentioned.mockResolvedValue(true); mocks.queueMessage.mockResolvedValue(true); + mocks.activateGoal.mockImplementation( + async ({ + objective, + deliver, + }: { + objective: string; + deliver: (goal: Record) => Promise; + }) => { + const goal = { + objective, + maxContinuations: 5, + generation: 'goal-generation:test', + status: 'active', + continuationsUsed: 0, + blockedReason: null, + completedAt: null, + }; + await deliver(goal); + return { success: true, goal }; + }, + ); mocks.enqueueGatewayEvent.mockResolvedValue({ jobId: 'event-message-1' }); mocks.callViaEmojiConfig.mockResolvedValue(null); }); @@ -1753,6 +1782,51 @@ describe('Discord Gateway event handler', () => { ); }); + it('activates /goal on an active task without injecting thread context', async () => { + mocks.findActiveRun.mockResolvedValue({ + id: 23, + taskId: 'task-23', + status: 'running', + }); + const interaction = { + id: 'interaction-goal', + application_id: 'app-1', + type: 2, + token: 'interaction-token', + channel_id: 'dm-1', + user: { id: 'discord-user-1', username: 'matt' }, + data: { + name: 'goal', + type: 1, + options: [{ name: 'objective', type: 3, value: 'Ship the release' }], + }, + }; + + const response = await postEvent( + envelope(interaction, 'INTERACTION_CREATE'), + ); + + expect(response.status).toBe(200); + expect(mocks.activateGoal).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: 'task-23', + objective: 'Ship the release', + }), + ); + expect(mocks.queueMessage).toHaveBeenCalledWith( + 'discord', + 23, + expect.objectContaining({ + text: 'Ship the release', + formattedPrompt: 'Ship the release', + goalContext: expect.objectContaining({ + generation: 'goal-generation:test', + }), + }), + ); + expect(mocks.buildContinuation).not.toHaveBeenCalled(); + }); + it('continues in the same thread when mentioned in an existing thread reply', async () => { mocks.getChannel.mockResolvedValue({ id: 'discussion-thread', diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index fa0eecf9b..756ccf707 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -24,6 +24,12 @@ import { setLatestInboundMessageId, } from '@roomote/communication/messages'; import { reactionEmojiMatches } from '@roomote/communication/reaction-emoji'; +import { activateTaskGoal } from '@roomote/communication/task-goal'; +import { + getTaskGoalActivationMessage, + parseGoalCommand, + withTaskGoalContext, +} from '@roomote/communication/task-goal-command'; import { getTaskUrl } from '@roomote/cloud-agents/server'; import { MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE, @@ -154,6 +160,7 @@ const DISCORD_HELP_MESSAGE = [ '', '**Available commands**', '`/new request:` — start a fresh task.', + '`/goal objective:` — keep the active task working toward an objective.', '`/link code:` — link this Discord account in a DM with me.', '`/help` — show this message.', '', @@ -508,7 +515,7 @@ async function processDiscordGatewayEvent( return { ok: true, linked: true }; } - if (command && command.name !== 'new') { + if (command && command.name !== 'new' && command.name !== 'goal') { return { ok: true, ignored: 'unsupported_command' }; } if (command?.name === 'new' && !command.request) { @@ -521,6 +528,16 @@ async function processDiscordGatewayEvent( }); return { ok: true, started: false, reason: 'missing_request' }; } + if (command?.name === 'goal' && !command.objective) { + await replyToDiscordEvent({ + provider: resolved.provider, + applicationId: resolved.applicationId, + channel, + interaction: interactionReplyContext(event), + text: 'Add what Roomote should keep working toward in the `objective` field.', + }); + return { ok: true, queued: false, reason: 'missing_objective' }; + } const senderUserId = await findDiscordMappedUserId(sender.id); const conversation = { @@ -797,6 +814,29 @@ async function processDiscordGatewayEvent( senderUserId, }); + const goalCommand = parseGoalCommand(queuedMessage.text); + if (goalCommand) { + const result = await activateTaskGoal({ + taskId: activeRun.taskId, + objective: goalCommand.objective, + deliver: async (goal) => + queueCommunicationMessageOnce( + 'discord', + activeRun.id, + withTaskGoalContext(queuedMessage, goal), + ), + }); + await replyToDiscordEvent({ + provider: resolved.provider, + applicationId: resolved.applicationId, + channel, + ...(interaction ? { interaction: interactionReplyContext(event) } : {}), + ...(message?.id ? { replyToMessageId: message.id } : {}), + text: getTaskGoalActivationMessage(result), + }); + return { ok: true, queued: result.success, runId: activeRun.id }; + } + if (message && queuedMessage) { const handledRequestUserInput = await tryHandleDiscordRequestUserInputMessage({ @@ -906,6 +946,18 @@ async function processDiscordGatewayEvent( return { ok: true, queued: true, runId: activeRun.id }; } + if (parseGoalCommand(queuedMessage.text)) { + await replyToDiscordEvent({ + provider: resolved.provider, + applicationId: resolved.applicationId, + channel, + ...(interaction ? { interaction: interactionReplyContext(event) } : {}), + ...(message?.id ? { replyToMessageId: message.id } : {}), + text: 'Use `/goal` in a conversation with an active task.', + }); + return { ok: true, queued: false, reason: 'goal_requires_active_task' }; + } + if (completedRun) { // Snapshot resume also carries Slack-style thread context so the restored // session sees earlier Discord messages, not only the resume trigger text. diff --git a/apps/api/src/handlers/slack/events/active-run.ts b/apps/api/src/handlers/slack/events/active-run.ts index 81fcd16ff..38190e449 100644 --- a/apps/api/src/handlers/slack/events/active-run.ts +++ b/apps/api/src/handlers/slack/events/active-run.ts @@ -31,6 +31,11 @@ import { stripLeadingSlackProductMention, } from '@roomote/cloud-agents'; import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server'; +import { activateTaskGoal } from '@roomote/communication/task-goal'; +import { + getTaskGoalActivationMessage, + parseGoalCommand, +} from '@roomote/communication/task-goal-command'; import { apiLogger } from '../../../logging.js'; import { retireSlackPrReviewOffersBestEffort } from '../pr-review-retire.js'; @@ -431,21 +436,59 @@ export async function processActiveRunMessage( } } - const [promptReadyThreadMessages, normalizedMessageText, trackedBotReply] = - await Promise.all([ - getPromptReadyThreadMessages({ - slack, - channel: event.channel, - threadTs: threadId, - botUserId, - startedMessageRunId: activeRun.id, - logContext: `active task run ${activeRun.id} in ${event.channel}:${threadId}`, - prefetchedMessages: prefetchedThreadMessages, - }), - slack.normalizeIncomingText(stripLeadingRawSlackMention(event.text)), - getLatestSlackBotReply(event.channel, threadId), - ]); + const normalizedMessageText = await slack.normalizeIncomingText( + stripLeadingRawSlackMention(event.text), + ); const messageText = stripLeadingSlackProductMention(normalizedMessageText); + const goalCommand = parseGoalCommand(messageText); + + if (goalCommand && activeRun.taskId) { + const result = await activateTaskGoal({ + taskId: activeRun.taskId, + objective: goalCommand.objective, + deliver: async (goal) => { + await syncActingUserForInboundMessage({ + logContext: 'slack.processActiveRunMessage.goal', + runId: activeRun.id, + senderUserId: userId, + }); + await queueSlackMessage(activeRun.id, { + text: goal.objective, + user: event.user, + userId, + ts: event.ts, + formattedPrompt: goal.objective, + goalContext: goal, + }); + }, + }); + + await slack.postMessage({ + channel: event.channel, + thread_ts: threadId, + blocks: [ + { + type: 'markdown', + text: getTaskGoalActivationMessage(result), + }, + ], + }); + deliveryTracker.track(event.ts); + return; + } + + const [promptReadyThreadMessages, trackedBotReply] = await Promise.all([ + getPromptReadyThreadMessages({ + slack, + channel: event.channel, + threadTs: threadId, + botUserId, + startedMessageRunId: activeRun.id, + logContext: `active task run ${activeRun.id} in ${event.channel}:${threadId}`, + prefetchedMessages: prefetchedThreadMessages, + }), + getLatestSlackBotReply(event.channel, threadId), + ]); const currentMessageFiles = resolveCurrentSlackMessageFiles({ currentMessageTs: deliveryTs, eventFiles: event.files, diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index f1e2756aa..3e709bfef 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -39,6 +39,7 @@ const { claimPendingOutOfBandMock, releaseClaimedOutOfBandMock, callViaEmojiConfigMock, + activateGoalMock, } = vi.hoisted(() => ({ authAccountsFindFirstMock: vi.fn(), authAccountsFindManyMock: vi.fn(), @@ -97,6 +98,7 @@ const { claimPendingOutOfBandMock: vi.fn(), releaseClaimedOutOfBandMock: vi.fn(), callViaEmojiConfigMock: vi.fn(), + activateGoalMock: vi.fn(), })); vi.mock('@roomote/env', () => ({ @@ -253,6 +255,13 @@ vi.mock('@roomote/communication/messages', () => ({ queueCommunicationMessage: queueCommunicationMessageMock, })); +vi.mock('@roomote/communication/task-goal', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@roomote/communication/task-goal') + >()), + activateTaskGoal: activateGoalMock, +})); + vi.mock('@roomote/communication/teams-provider', () => ({ TeamsCommunicationProvider: vi.fn().mockImplementation(function () { return { @@ -366,6 +375,27 @@ describe('Teams webhook handler', () => { payload: {}, }); queueCommunicationMessageMock.mockResolvedValue(undefined); + activateGoalMock.mockImplementation( + async ({ + objective, + deliver, + }: { + objective: string; + deliver: (goal: Record) => Promise; + }) => { + const goal = { + objective, + maxContinuations: 5, + generation: 'goal-generation:test', + status: 'active', + continuationsUsed: 0, + blockedReason: null, + completedAt: null, + }; + await deliver(goal); + return { success: true, goal }; + }, + ); claimPendingOutOfBandMock.mockResolvedValue([]); releaseClaimedOutOfBandMock.mockResolvedValue(undefined); buildTeamsRoutingContextMock.mockResolvedValue({ context: true }); @@ -693,6 +723,42 @@ describe('Teams webhook handler', () => { }); }); + it('activates /goal on an active task and queues only the objective', async () => { + teamsUserMappingFindFirstMock.mockResolvedValueOnce({ + userId: 'mapped-user-1', + }); + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer bot-framework-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + id: 'activity-goal', + text: 'Roomote /goal ship the release', + }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: true, + runId: 77, + }); + expect(queueCommunicationMessageMock).toHaveBeenCalledWith( + 'teams', + 77, + expect.objectContaining({ + text: 'ship the release', + formattedPrompt: 'ship the release', + goalContext: expect.objectContaining({ + generation: 'goal-generation:test', + }), + }), + ); + }); + it('ignores bot-authored Teams message activities before queueing or launching', async () => { const response = await createApp().request('/teams', { method: 'POST', diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index 2f07d8738..59633d644 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -18,6 +18,12 @@ import { teamsActivityToQueuedCommunicationMessage, } from '@roomote/communication/teams-activity'; import { queueCommunicationMessage } from '@roomote/communication/messages'; +import { activateTaskGoal } from '@roomote/communication/task-goal'; +import { + getTaskGoalActivationMessage, + parseGoalCommand, + withTaskGoalContext, +} from '@roomote/communication/task-goal-command'; import { buildAccountLinkPromptText, buildAccountLinkThreadReplyText, @@ -2231,6 +2237,28 @@ teams.post('/', async (c) => { senderUserId: mappedUserId, }); + const goalCommand = parseGoalCommand(queuedMessage.text); + if (goalCommand) { + const result = await activateTaskGoal({ + taskId: activeRun.taskId, + objective: goalCommand.objective, + deliver: async (goal) => { + await queueCommunicationMessage( + 'teams', + activeRun.id, + withTaskGoalContext(queuedMessage, goal), + ); + }, + }); + await postTeamsMessageBestEffort({ + conversationId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId, + serviceUrl: metadata.communicationServiceUrl, + text: getTaskGoalActivationMessage(result), + }); + return c.json({ ok: true, queued: result.success, runId: activeRun.id }); + } + if (mappedUserId && queuedMessage.text?.trim()) { const { tryHandleTeamsRequestUserInputMessage } = await import('./request-user-input.js'); diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index 5c33fb1fa..aa11f8981 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -40,6 +40,7 @@ const { usersFindFirstMock, telegramMappingsFindFirstMock, appendAccountLinkHelpTextMock, + activateGoalMock, } = vi.hoisted(() => ({ addReactionMock: vi.fn(), answerCallbackQueryMock: vi.fn(), @@ -84,6 +85,7 @@ const { usersFindFirstMock: vi.fn(), telegramMappingsFindFirstMock: vi.fn(), appendAccountLinkHelpTextMock: vi.fn(async (message: string) => message), + activateGoalMock: vi.fn(), })); vi.mock('@roomote/env', () => ({ @@ -246,6 +248,13 @@ vi.mock('@roomote/communication/messages', () => ({ setLatestInboundMessageId: setLatestInboundMessageIdMock, })); +vi.mock('@roomote/communication/task-goal', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@roomote/communication/task-goal') + >()), + activateTaskGoal: activateGoalMock, +})); + vi.mock('@roomote/sdk/server', () => ({ createTelegramCommunicationProviderFromRuntimeCredentials: vi.fn(async () => envMock.R_TELEGRAM_BOT_TOKEN @@ -417,6 +426,27 @@ describe('Telegram webhook handler', () => { }); updateReturningMock.mockResolvedValue([]); queueCommunicationMessageMock.mockResolvedValue(undefined); + activateGoalMock.mockImplementation( + async ({ + objective, + deliver, + }: { + objective: string; + deliver: (goal: Record) => Promise; + }) => { + const goal = { + objective, + maxContinuations: 5, + generation: 'goal-generation:test', + status: 'active', + continuationsUsed: 0, + blockedReason: null, + completedAt: null, + }; + await deliver(goal); + return { success: true, goal }; + }, + ); buildTelegramRoutingContextMock.mockResolvedValue({ context: true }); classifyFollowUpMock.mockResolvedValue({ intent: 'correct', @@ -746,6 +776,43 @@ describe('Telegram webhook handler', () => { }); }); + it('activates /goal on an active task and queues only the objective', async () => { + mockTelegramLinkedSender(); + taskRunsFindFirstMock.mockResolvedValueOnce({ + id: 77, + status: 'running', + machineId: 'machine-1', + taskId: 'task-1', + payload: {}, + }); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + text: '/goal ship the release', + entities: [{ type: 'bot_command', offset: 0, length: 5 }], + }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: true, + runId: 77, + }); + expect(queueCommunicationMessageMock).toHaveBeenCalledWith( + 'telegram', + 77, + expect.objectContaining({ + text: 'ship the release', + formattedPrompt: 'ship the release', + goalContext: expect.objectContaining({ + generation: 'goal-generation:test', + }), + }), + ); + }); + it('queues a captioned photo as an active-run follow-up', async () => { mockTelegramLinkedSender(); taskRunsFindFirstMock.mockResolvedValueOnce({ diff --git a/apps/api/src/handlers/telegram/index.ts b/apps/api/src/handlers/telegram/index.ts index 4ac032da0..3f3e65035 100644 --- a/apps/api/src/handlers/telegram/index.ts +++ b/apps/api/src/handlers/telegram/index.ts @@ -18,6 +18,7 @@ import { getTelegramUpdateMessage, getTelegramUpdateMessageReaction, getTelegramNewTaskCommand, + getTelegramGoalCommand, isTelegramImplicitTopicCreatedMessage, isTelegramPrivateChat, isTelegramStartCommand, @@ -26,6 +27,11 @@ import { parseTelegramUpdate, telegramUpdateToQueuedCommunicationMessage, } from '@roomote/communication/telegram-update'; +import { activateTaskGoal } from '@roomote/communication/task-goal'; +import { + getTaskGoalActivationMessage, + withTaskGoalContext, +} from '@roomote/communication/task-goal-command'; import { apiLogger } from '../../logging.js'; import { syncActingUserForInboundMessage } from '../tasks/acting-user-sync.js'; @@ -60,6 +66,7 @@ const TELEGRAM_COMMAND_HELP = [ '*Available commands*', '`/start` — show this welcome message.', '`/new ` — start a fresh task instead of resuming the previous one; when topics are available, it opens a new topic.', + '`/goal ` — keep the active task working toward an objective.', ].join('\n'); const TELEGRAM_WELCOME_MESSAGE = [ @@ -400,8 +407,11 @@ telegram.post('/', async (c) => { const newTaskCommand = getTelegramNewTaskCommand(update, { botUsername: botUsername ?? undefined, }); + const goalCommand = getTelegramGoalCommand(update, { + botUsername: botUsername ?? undefined, + }); - if (!queuedMessage && !newTaskCommand) { + if (!queuedMessage && !newTaskCommand && !goalCommand) { return c.json({ ok: true, ignored: 'unsupported_update' }); } @@ -480,6 +490,33 @@ telegram.post('/', async (c) => { return c.json({ ok: true, ignored: 'unsupported_update' }); } + if (goalCommand) { + const result = await activateTaskGoal({ + taskId: activeRun.taskId, + objective: goalCommand.objective, + deliver: async (goal) => { + await syncActingUserForInboundMessage({ + logContext: 'telegram.activeRunMessage.goal', + runId: activeRun.id, + senderUserId: queuedMessage.userId, + }); + await queueCommunicationMessage( + 'telegram', + activeRun.id, + withTaskGoalContext(queuedMessage, goal), + ); + }, + }); + await postTelegramMessageBestEffort({ + chatId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId, + replyToMessageId: metadata.communicationMessageId, + text: getTaskGoalActivationMessage(result), + textFormat: 'markdown', + }); + return c.json({ ok: true, queued: result.success, runId: activeRun.id }); + } + // Prefer structured request_user_input answers over plain¡ follow-ups. if (queuedMessage.userId && queuedMessage.text?.trim()) { const { tryHandleTelegramRequestUserInputMessage } = @@ -543,6 +580,21 @@ telegram.post('/', async (c) => { return c.json({ ok: true, queued: true, runId: activeRun.id }); } + if (goalCommand) { + await postTelegramMessageBestEffort({ + chatId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId, + replyToMessageId: metadata.communicationMessageId, + text: 'Use `/goal` in a chat with an active task.', + textFormat: 'markdown', + }); + return c.json({ + ok: true, + queued: false, + reason: 'goal_requires_active_task', + }); + } + if (newTaskCommand && !newTaskCommand.text) { await postTelegramMessageBestEffort({ chatId: metadata.communicationChannelId, diff --git a/apps/web/src/trpc/commands/task-runs/index.ts b/apps/web/src/trpc/commands/task-runs/index.ts index d89feb3c8..22a435fa7 100644 --- a/apps/web/src/trpc/commands/task-runs/index.ts +++ b/apps/web/src/trpc/commands/task-runs/index.ts @@ -20,6 +20,7 @@ import { routeTask, } from '@roomote/cloud-agents/server'; import { captureTaskSettled } from '@roomote/telemetry/server'; +import { activateTaskGoal } from '@roomote/communication/task-goal'; import { and, db, @@ -27,7 +28,6 @@ import { eq, inArray, markTaskStartParallelCountEndedAt, - prepareTaskGoalActivation, slackInstallations, taskRuns, tasks, @@ -67,52 +67,31 @@ export async function startTaskGoalCommand( return { success: false, error: 'Task not found' }; } - const activation = await prepareTaskGoalActivation({ + const result = await activateTaskGoal({ taskId: input.taskId, - goal: input.goal, - }); - if (!activation) { - return { success: false, error: 'Goal Mode activation is already pending' }; - } - - try { - await sendSandboxPromptCommand( - auth, - { - taskId: input.taskId, - prompt: input.goal.objective, - source: 'web', - clientMessageId: input.clientMessageId, - userImageUrl: input.userImageUrl, - autoSteerWhenQueued: true, - }, - { - goalContext: { - ...input.goal, - generation: activation.generation, - status: 'active', - continuationsUsed: 0, - blockedReason: null, - completedAt: null, + objective: input.goal.objective, + maxContinuations: input.goal.maxContinuations, + deliver: async (goal) => { + await sendSandboxPromptCommand( + auth, + { + taskId: input.taskId, + prompt: input.goal.objective, + source: 'web', + clientMessageId: input.clientMessageId, + userImageUrl: input.userImageUrl, + autoSteerWhenQueued: true, }, - }, - ); - } catch (error) { - try { - await activation.rollback(); - } catch (rollbackError) { - console.error('Failed to roll back Goal Mode activation:', rollbackError); - } - throw error; - } + { goalContext: goal }, + ); + }, + }); - const goal = await activation.commit(); - if (!goal) { - await activation.rollback(); + if (!result.success) { return { success: false, error: 'Goal Mode activation was superseded' }; } - return { success: true, goal }; + return { success: true, goal: result.goal }; } type CreateStandardTaskRunInput = { diff --git a/apps/worker/src/run-task/polling/communication.ts b/apps/worker/src/run-task/polling/communication.ts index 717e133c1..4162cd7e2 100644 --- a/apps/worker/src/run-task/polling/communication.ts +++ b/apps/worker/src/run-task/polling/communication.ts @@ -254,6 +254,7 @@ export function createCommunicationMessageInterval({ // The delivered sender always equals the server-side acting user. userId: msgPrep.effectiveUserId ?? undefined, clientMessageId: getCommunicationClientMessageId(provider, message), + goalContext: message.goalContext, }); if (!sent) { diff --git a/apps/worker/src/run-task/polling/slack.ts b/apps/worker/src/run-task/polling/slack.ts index 3e30e6cd5..f289230c0 100644 --- a/apps/worker/src/run-task/polling/slack.ts +++ b/apps/worker/src/run-task/polling/slack.ts @@ -251,6 +251,7 @@ export function createSlackMessageInterval({ // The delivered sender always equals the server-side acting user. userId: msgPrep.effectiveUserId ?? undefined, clientMessageId: getSlackClientMessageId(msg), + goalContext: msg.goalContext, }); logger.log( diff --git a/apps/worker/src/run-task/run-task.ts b/apps/worker/src/run-task/run-task.ts index 5fcefa1e9..ae208297c 100644 --- a/apps/worker/src/run-task/run-task.ts +++ b/apps/worker/src/run-task/run-task.ts @@ -14,6 +14,7 @@ import { RunStatus, TaskPayloadKind, type QueuedCommunicationMessage, + type TaskGoal, getSlackChannelFromTaskPayload, getSlackThreadTsFromTaskPayload, isCommunicationProvider, @@ -1693,6 +1694,7 @@ export const runTask = async ({ userName?: string; userImageUrl?: string; clientMessageId?: string; + goalContext?: TaskGoal; }) => { const workflowPhase = options.workflowPhase ?? getFollowUpWorkflowPhase(options.prompt); diff --git a/apps/worker/src/run-task/types.ts b/apps/worker/src/run-task/types.ts index 950c608c9..d433a47e7 100644 --- a/apps/worker/src/run-task/types.ts +++ b/apps/worker/src/run-task/types.ts @@ -6,6 +6,7 @@ import type { CommunicationProvider, EnvironmentConfig, RequestedWorkKind, + TaskGoal, } from '@roomote/types'; import type { TaskRun, DequeuedTaskRun } from '@roomote/sdk/client'; @@ -293,6 +294,7 @@ export interface ListenerOptions { userName?: string; userImageUrl?: string; clientMessageId?: string; + goalContext?: TaskGoal; }) => boolean; slackReplySatisfactionStateFile?: string; answerUserInputRequest: (options: { diff --git a/packages/communication/package.json b/packages/communication/package.json index f61e762f8..031cc28e4 100644 --- a/packages/communication/package.json +++ b/packages/communication/package.json @@ -16,6 +16,8 @@ "./redact-secrets": "./src/redact-secrets.ts", "./request-user-input": "./src/request-user-input.ts", "./task-thread-title": "./src/task-thread-title.ts", + "./task-goal": "./src/task-goal.ts", + "./task-goal-command": "./src/task-goal-command.ts", "./teams-activity": "./src/teams-activity.ts", "./teams-bot-framework-client": "./src/teams-bot-framework-client.ts", "./teams-credential-validation": "./src/teams-credential-validation.ts", diff --git a/packages/communication/src/__tests__/discord-event.test.ts b/packages/communication/src/__tests__/discord-event.test.ts index 251ef7c21..229060b83 100644 --- a/packages/communication/src/__tests__/discord-event.test.ts +++ b/packages/communication/src/__tests__/discord-event.test.ts @@ -286,6 +286,38 @@ describe('Discord Gateway event normalization', () => { }); }); + it('normalizes /goal slash interactions', () => { + const event = parse({ + op: 0, + t: 'INTERACTION_CREATE', + s: 2, + d: { + id: 'interaction-goal', + application_id: 'application-1', + type: 2, + token: 'interaction-token', + channel_id: 'channel-1', + user: { id: 'user-1', username: 'matt' }, + data: { + name: 'goal', + type: 1, + options: [ + { name: 'objective', type: 3, value: 'Ship Discord support' }, + ], + }, + }, + }); + + expect(getDiscordInteractionCommand(event)).toEqual({ + name: 'goal', + objective: 'Ship Discord support', + }); + expect(discordEventToQueuedCommunicationMessage(event)).toMatchObject({ + text: '/goal Ship Discord support', + ts: 'interaction-goal', + }); + }); + it('does not queue bot-authored messages or non-task commands', () => { const botEvent = parse({ op: 0, diff --git a/packages/communication/src/__tests__/discord-provider.test.ts b/packages/communication/src/__tests__/discord-provider.test.ts index d89ed16eb..4aa8a1dc9 100644 --- a/packages/communication/src/__tests__/discord-provider.test.ts +++ b/packages/communication/src/__tests__/discord-provider.test.ts @@ -221,6 +221,13 @@ describe('DiscordCommunicationProvider', () => { type: 1, options: [expect.objectContaining({ name: 'request', required: true })], }, + { + name: 'goal', + type: 1, + options: [ + expect.objectContaining({ name: 'objective', required: true }), + ], + }, { name: 'link', type: 1 }, { 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 cbbd35f59..0aea7829f 100644 --- a/packages/communication/src/__tests__/mock-telegram-server.test.ts +++ b/packages/communication/src/__tests__/mock-telegram-server.test.ts @@ -84,6 +84,7 @@ describe('MockTelegramServer', () => { expect(server.getState().botCommands).toEqual([ { command: 'start', description: 'Show welcome and command help' }, { command: 'new', description: 'Start a fresh task' }, + { command: 'goal', description: 'Set a goal for the active task' }, ]); }); diff --git a/packages/communication/src/__tests__/task-goal.test.ts b/packages/communication/src/__tests__/task-goal.test.ts new file mode 100644 index 000000000..b16bcca6f --- /dev/null +++ b/packages/communication/src/__tests__/task-goal.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + prepare: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + prepareTaskGoalActivation: mocks.prepare, +})); + +import { parseGoalCommand } from '../task-goal-command'; +import { activateTaskGoal } from '../task-goal'; + +describe('task goal communication helpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.prepare.mockResolvedValue({ + generation: 'goal-generation:test', + commit: mocks.commit, + rollback: mocks.rollback, + }); + mocks.commit.mockResolvedValue({ + objective: 'ship the release', + maxContinuations: 5, + generation: 'goal-generation:test', + status: 'active', + continuationsUsed: 0, + blockedReason: null, + completedAt: null, + }); + mocks.rollback.mockResolvedValue(true); + }); + + it('parses only complete goal commands', () => { + expect(parseGoalCommand('/goal ship the release')).toEqual({ + objective: 'ship the release', + }); + expect(parseGoalCommand('please /goal ship')).toBeNull(); + }); + + it('commits only after delivery accepts the trusted goal context', async () => { + const deliver = vi.fn().mockResolvedValue(true); + + await expect( + activateTaskGoal({ + taskId: 'task-1', + objective: 'ship the release', + deliver, + }), + ).resolves.toMatchObject({ success: true }); + + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ + objective: 'ship the release', + generation: 'goal-generation:test', + }), + ); + expect(mocks.commit).toHaveBeenCalledAfter(deliver); + expect(mocks.rollback).not.toHaveBeenCalled(); + }); + + it('rolls back when delivery rejects the goal turn', async () => { + await expect( + activateTaskGoal({ + taskId: 'task-1', + objective: 'ship the release', + deliver: async () => false, + }), + ).resolves.toEqual({ success: false, error: 'delivery_rejected' }); + + expect(mocks.rollback).toHaveBeenCalledOnce(); + expect(mocks.commit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/communication/src/__tests__/telegram-provider.test.ts b/packages/communication/src/__tests__/telegram-provider.test.ts index 832b13744..c133d6051 100644 --- a/packages/communication/src/__tests__/telegram-provider.test.ts +++ b/packages/communication/src/__tests__/telegram-provider.test.ts @@ -29,6 +29,7 @@ describe('TelegramCommunicationProvider', () => { commands: [ { command: 'start', description: 'Show welcome and command help' }, { command: 'new', description: 'Start a fresh task' }, + { command: 'goal', description: 'Set a goal for the active task' }, ], }); }); diff --git a/packages/communication/src/__tests__/telegram-update.test.ts b/packages/communication/src/__tests__/telegram-update.test.ts index 4bb6bf0ba..f35aca7a7 100644 --- a/packages/communication/src/__tests__/telegram-update.test.ts +++ b/packages/communication/src/__tests__/telegram-update.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getTelegramGoalCommand, getTelegramNewTaskCommand, getTelegramUpdateCallbackQuery, getTelegramUpdateCommunicationMetadata, @@ -13,6 +14,25 @@ import { } from '../telegram-update'; describe('Telegram update helpers', () => { + it('parses addressed goal commands', () => { + const parsed = parseTelegramUpdate({ + update_id: 1, + message: { + message_id: 2, + chat: { id: 3, type: 'group' }, + text: '/goal@roomote_bot ship the release', + entities: [{ type: 'bot_command', offset: 0, length: 17 }], + }, + }).data!; + + expect( + getTelegramGoalCommand(parsed, { botUsername: 'roomote_bot' }), + ).toEqual({ objective: 'ship the release' }); + expect( + getTelegramGoalCommand(parsed, { botUsername: 'another_bot' }), + ).toBeNull(); + }); + it('recognizes /start commands in private chats only', () => { const buildUpdate = (text: string, chatType = 'private') => ({ update_id: 1, diff --git a/packages/communication/src/discord-event.ts b/packages/communication/src/discord-event.ts index efd363093..dd319c144 100644 --- a/packages/communication/src/discord-event.ts +++ b/packages/communication/src/discord-event.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { QueuedCommunicationMessage } from '@roomote/types'; +import { GOAL_COMMAND, GOAL_COMMAND_NAME } from './task-goal-command'; export const discordUserSchema = z .object({ @@ -533,7 +534,12 @@ function findInteractionOption( export function getDiscordInteractionCommand( eventOrInteraction: DiscordGatewayEvent | DiscordInteraction, -): { name: string; request?: string; code?: string } | null { +): { + name: string; + request?: string; + objective?: string; + code?: string; +} | null { const interaction = isDiscordGatewayEventValue(eventOrInteraction) ? getDiscordInteractionCreate(eventOrInteraction) : eventOrInteraction; @@ -545,12 +551,19 @@ export function getDiscordInteractionCommand( 'request', )?.value; const code = findInteractionOption(interaction.data.options, 'code')?.value; + const objective = findInteractionOption( + interaction.data.options, + 'objective', + )?.value; return { name: interaction.data.name.toLowerCase(), ...(typeof request === 'string' && request.trim() ? { request: request.trim() } : {}), ...(typeof code === 'string' && code.trim() ? { code: code.trim() } : {}), + ...(typeof objective === 'string' && objective.trim() + ? { objective: objective.trim() } + : {}), }; } @@ -573,7 +586,8 @@ export function isDiscordTaskEntryEvent( isDiscordBotMentioned(message, options.botUserId)) ); } - return getDiscordInteractionCommand(event)?.name === 'new'; + const commandName = getDiscordInteractionCommand(event)?.name; + return commandName === 'new' || commandName === GOAL_COMMAND_NAME; } function formatDiscordUser(input: { @@ -637,11 +651,19 @@ export function discordEventToQueuedCommunicationMessage( const interaction = getDiscordInteractionCreate(event); const command = getDiscordInteractionCommand(event); - if (!interaction || command?.name !== 'new') return null; + if ( + !interaction || + (command?.name !== 'new' && command?.name !== GOAL_COMMAND_NAME) + ) { + return null; + } const user = getDiscordInteractionUser(interaction); return { provider: 'discord', - text: command.request ?? 'Start a new task', + text: + command.name === GOAL_COMMAND_NAME + ? `${GOAL_COMMAND} ${command.objective ?? ''}`.trim() + : (command.request ?? 'Start a new task'), user: formatDiscordUser({ user, nickname: interaction.member?.nick }), ...(options.userId ? { userId: options.userId } : {}), ts: interaction.id, diff --git a/packages/communication/src/discord-provider.ts b/packages/communication/src/discord-provider.ts index ed60dddfa..fa8f2804d 100644 --- a/packages/communication/src/discord-provider.ts +++ b/packages/communication/src/discord-provider.ts @@ -8,6 +8,7 @@ import type { CommunicationReactionResult, CommunicationThreadLookupResult, } from './provider'; +import { GOAL_COMMAND_NAME } from './task-goal-command'; export const DISCORD_MAX_MESSAGE_LENGTH = 2_000; const DISCORD_MAX_EMBEDS_PER_MESSAGE = 10; @@ -1204,6 +1205,19 @@ export class DiscordCommunicationProvider implements CommunicationProviderAdapte }, ], }, + { + name: GOAL_COMMAND_NAME, + description: 'Set a goal for the active Roomote task', + type: 1, + options: [ + { + type: 3, + name: 'objective', + description: 'What should Roomote keep working toward?', + required: true, + }, + ], + }, { name: 'link', description: 'Link this Discord account to Roomote', diff --git a/packages/communication/src/index.ts b/packages/communication/src/index.ts index 41fddcbad..708127d02 100644 --- a/packages/communication/src/index.ts +++ b/packages/communication/src/index.ts @@ -7,6 +7,8 @@ export * from './provider'; export * from './reaction-emoji'; export * from './request-user-input'; export * from './task-thread-title'; +export * from './task-goal'; +export * from './task-goal-command'; export * from './teams-activity'; export * from './teams-bot-framework-client'; export * from './teams-credential-validation'; diff --git a/packages/communication/src/task-goal-command.ts b/packages/communication/src/task-goal-command.ts new file mode 100644 index 000000000..437542de8 --- /dev/null +++ b/packages/communication/src/task-goal-command.ts @@ -0,0 +1,50 @@ +import type { QueuedCommunicationMessage, TaskGoal } from '@roomote/types'; + +export const GOAL_COMMAND_NAME = 'goal'; +export const GOAL_COMMAND = `/${GOAL_COMMAND_NAME}`; + +export type GoalCommand = { objective: string }; +export type TaskGoalActivationError = + | 'invalid_goal' + | 'activation_pending' + | 'delivery_rejected'; + +const goalCommandPattern = new RegExp( + `^\\/${GOAL_COMMAND_NAME}(?:\\s+([\\s\\S]*))?$`, + 'iu', +); + +export function parseGoalCommand(text: string): GoalCommand | null { + const match = goalCommandPattern.exec(text.trim()); + return match ? { objective: (match[1] ?? '').trim() } : null; +} + +export function withTaskGoalContext( + message: QueuedCommunicationMessage, + goal: TaskGoal, +): QueuedCommunicationMessage { + return { + ...message, + text: goal.objective, + images: undefined, + formattedPrompt: goal.objective, + goalContext: goal, + }; +} + +export function getTaskGoalActivationMessage( + result: + | { success: true } + | { success: false; error: TaskGoalActivationError }, +): string { + if (result.success) { + return 'Goal Mode enabled.'; + } + if (result.error === 'invalid_goal') { + return `Add an objective after \`${GOAL_COMMAND}\`.`; + } + if (result.error === 'delivery_rejected') { + return 'Goal Mode could not deliver the objective. Try again.'; + } + return 'Goal Mode activation is already in progress. Try again shortly.'; +} diff --git a/packages/communication/src/task-goal.ts b/packages/communication/src/task-goal.ts new file mode 100644 index 000000000..a710595e3 --- /dev/null +++ b/packages/communication/src/task-goal.ts @@ -0,0 +1,62 @@ +import { prepareTaskGoalActivation } from '@roomote/db/server'; +import { + DEFAULT_TASK_GOAL_MAX_CONTINUATIONS, + taskGoalInputSchema, + type TaskGoal, +} from '@roomote/types'; +import type { TaskGoalActivationError } from './task-goal-command'; + +export async function activateTaskGoal(input: { + taskId: string; + objective: string; + maxContinuations?: number; + deliver: (goal: TaskGoal) => Promise; +}): Promise< + | { success: true; goal: TaskGoal } + | { success: false; error: TaskGoalActivationError } +> { + const parsed = taskGoalInputSchema.safeParse({ + objective: input.objective, + maxContinuations: + input.maxContinuations ?? DEFAULT_TASK_GOAL_MAX_CONTINUATIONS, + }); + if (!parsed.success) { + return { success: false, error: 'invalid_goal' }; + } + + const activation = await prepareTaskGoalActivation({ + taskId: input.taskId, + goal: parsed.data, + }); + if (!activation) { + return { success: false, error: 'activation_pending' }; + } + + const pendingGoal: TaskGoal = { + ...parsed.data, + generation: activation.generation, + status: 'active', + continuationsUsed: 0, + blockedReason: null, + completedAt: null, + }; + + try { + const delivered = await input.deliver(pendingGoal); + if (delivered === false) { + await activation.rollback(); + return { success: false, error: 'delivery_rejected' }; + } + } catch (error) { + await activation.rollback().catch(() => false); + throw error; + } + + const goal = await activation.commit(); + if (!goal) { + await activation.rollback(); + return { success: false, error: 'activation_pending' }; + } + + return { success: true, goal }; +} diff --git a/packages/communication/src/telegram-provider.ts b/packages/communication/src/telegram-provider.ts index c9551e21e..c5a38b6df 100644 --- a/packages/communication/src/telegram-provider.ts +++ b/packages/communication/src/telegram-provider.ts @@ -10,6 +10,7 @@ import type { import { UnsupportedCommunicationOperationError } from './provider'; import { readBoundedResponseBody } from './bounded-response-body'; import { getTelegramApiBaseUrl } from './telegram-api-base-url'; +import { GOAL_COMMAND_NAME } from './task-goal-command'; import { TELEGRAM_MAX_MESSAGE_LENGTH, chunkTelegramMarkdown, @@ -527,6 +528,10 @@ export class TelegramCommunicationProvider implements CommunicationProviderAdapt commands: [ { command: 'start', description: 'Show welcome and command help' }, { command: 'new', description: 'Start a fresh task' }, + { + command: GOAL_COMMAND_NAME, + description: 'Set a goal for the active task', + }, ], }); } diff --git a/packages/communication/src/telegram-update.ts b/packages/communication/src/telegram-update.ts index 19f2e0965..20e6ad586 100644 --- a/packages/communication/src/telegram-update.ts +++ b/packages/communication/src/telegram-update.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { QueuedCommunicationMessage } from '@roomote/types'; +import { GOAL_COMMAND_NAME } from './task-goal-command'; const telegramUserSchema = z .object({ @@ -525,6 +526,8 @@ export type TelegramNewTaskCommand = { text: string; }; +export type TelegramGoalCommand = { objective: string }; + function isNewTaskCommandName( command: string, ): command is TelegramNewTaskCommand['command'] { @@ -610,6 +613,59 @@ export function getTelegramNewTaskCommand( return null; } +export function getTelegramGoalCommand( + update: TelegramUpdate, + options: TelegramBotMentionOptions = {}, +): TelegramGoalCommand | null { + const message = getTelegramUpdateMessage(update); + const text = message?.text; + + if (!text) { + return null; + } + + const botUsername = normalizeTelegramBotUsername(options.botUsername); + const entities = message.entities ?? []; + + for (const entity of entities) { + if (entity.type !== 'bot_command') { + continue; + } + + const parsed = parseTelegramBotCommand(readEntityText(text, entity)); + if (!parsed || parsed.command !== GOAL_COMMAND_NAME) { + continue; + } + if (botUsername && parsed.botSuffix && parsed.botSuffix !== botUsername) { + continue; + } + if (!isLeadingBotCommand(text, entities, entity, options)) { + continue; + } + + const mentionPrefix = findLeadingBotMentionBefore( + text, + entities, + entity.offset, + options, + ); + if ( + botUsername && + !isTelegramPrivateChat(message) && + !parsed.botSuffix && + !mentionPrefix + ) { + continue; + } + + return { + objective: normalizeWhitespace(text.slice(entity.offset + entity.length)), + }; + } + + return null; +} + export function stripTelegramBotInvocation( text: string, message: TelegramMessage, diff --git a/packages/types/src/communication.ts b/packages/types/src/communication.ts index 1054c067a..20b372e70 100644 --- a/packages/types/src/communication.ts +++ b/packages/types/src/communication.ts @@ -91,6 +91,17 @@ export const queuedCommunicationMessageSchema = z.object({ }) .optional(), contextOnly: z.boolean().optional(), + goalContext: z + .object({ + objective: z.string(), + maxContinuations: z.number().int(), + generation: z.string().nullable(), + status: z.enum(['active', 'complete', 'blocked', 'budget_limited']), + continuationsUsed: z.number().int(), + blockedReason: z.string().nullable(), + completedAt: z.coerce.date().nullable(), + }) + .optional(), }); export type QueuedCommunicationMessage = z.infer<