diff --git a/apps/api/src/handlers/discord/__tests__/goal-command.test.ts b/apps/api/src/handlers/discord/__tests__/goal-command.test.ts new file mode 100644 index 000000000..ecf7d62f9 --- /dev/null +++ b/apps/api/src/handlers/discord/__tests__/goal-command.test.ts @@ -0,0 +1,94 @@ +const mocks = vi.hoisted(() => ({ + prepareActivation: vi.fn(), + sendMessage: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + prepareTaskGoalActivation: mocks.prepareActivation, +})); + +vi.mock('../../tasks/sendMessageToTask.js', () => ({ + sendMessageToTask: mocks.sendMessage, +})); + +import { startDiscordTaskGoal } from '../goal-command.js'; + +describe('startDiscordTaskGoal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.prepareActivation.mockResolvedValue({ + generation: 'goal-generation:1', + commit: mocks.commit, + rollback: mocks.rollback, + }); + mocks.sendMessage.mockResolvedValue({ success: true, result: {} }); + mocks.commit.mockResolvedValue({ objective: 'Ship the release' }); + mocks.rollback.mockResolvedValue(true); + }); + + it('delivers the objective with trusted Goal Mode context before committing', async () => { + await expect( + startDiscordTaskGoal({ + taskId: 'task-1', + userId: 'user-1', + objective: 'Ship the release', + clientMessageId: 'interaction-1', + }), + ).resolves.toEqual({ success: true }); + + expect(mocks.sendMessage).toHaveBeenCalledWith({ + taskId: 'task-1', + userId: 'user-1', + message: 'Ship the release', + source: 'discord', + clientMessageId: 'interaction-1', + goalContext: expect.objectContaining({ + objective: 'Ship the release', + generation: 'goal-generation:1', + status: 'active', + }), + }); + expect(mocks.commit).toHaveBeenCalledOnce(); + expect(mocks.rollback).not.toHaveBeenCalled(); + }); + + it('rolls back activation when prompt delivery fails', async () => { + mocks.sendMessage.mockResolvedValue({ + success: false, + error: 'Task has no active sandbox.', + status: 409, + }); + + await expect( + startDiscordTaskGoal({ + taskId: 'task-1', + userId: 'user-1', + objective: 'Ship the release', + clientMessageId: 'interaction-1', + }), + ).resolves.toEqual({ + success: false, + error: 'Task has no active sandbox.', + }); + + expect(mocks.rollback).toHaveBeenCalledOnce(); + expect(mocks.commit).not.toHaveBeenCalled(); + }); + + it('rolls back activation when commit fails', async () => { + mocks.commit.mockRejectedValue(new Error('database unavailable')); + + await expect( + startDiscordTaskGoal({ + taskId: 'task-1', + userId: 'user-1', + objective: 'Ship the release', + clientMessageId: 'interaction-1', + }), + ).rejects.toThrow('database unavailable'); + + expect(mocks.rollback).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 81fd2985e..7b3f7b78a 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -56,6 +56,7 @@ const mocks = vi.hoisted(() => ({ enqueueGatewayEvent: vi.fn(), callViaEmojiConfig: vi.fn(), appendAccountLinkHelpText: vi.fn(async (message: string) => message), + startGoal: vi.fn(), })); vi.mock('../../account-link-help.js', () => ({ @@ -155,6 +156,10 @@ vi.mock('../task-orchestration.js', () => ({ startNewDiscordTask: mocks.startNewTask, })); +vi.mock('../goal-command.js', () => ({ + startDiscordTaskGoal: mocks.startGoal, +})); + vi.mock('../replies.js', () => ({ replyToDiscordEvent: mocks.reply })); vi.mock('../callback-actions.js', () => ({ @@ -272,6 +277,7 @@ describe('Discord Gateway event handler', () => { status: 'started', launchResult: { id: 17, taskId: 'task-17' }, }); + mocks.startGoal.mockResolvedValue({ success: true }); mocks.reply.mockResolvedValue({ messageId: 'reply-1' }); mocks.createDirectMessage.mockResolvedValue({ id: 'dm-private-1' }); mocks.postMessage.mockResolvedValue({ messageId: 'dm-msg-1' }); @@ -1712,6 +1718,13 @@ describe('Discord Gateway event handler', () => { text: expect.stringContaining('/new'), }), ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining( + 'keep working toward an objective across multiple turns', + ), + }), + ); }); it('uses /new to start fresh even when the DM has an active task', async () => { @@ -1753,6 +1766,77 @@ describe('Discord Gateway event handler', () => { ); }); + it('uses /goal to enable Goal Mode on the active task', async () => { + mocks.findActiveRun.mockResolvedValue({ + id: 23, + taskId: 'task-23', + actingUserId: 'roomote-user-1', + }); + 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.startGoal).toHaveBeenCalledWith({ + taskId: 'task-23', + userId: 'roomote-user-1', + objective: 'Ship the release', + clientMessageId: 'interaction-goal', + }); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + interaction: { interaction, interactionDeferred: true }, + text: 'Goal Mode enabled.', + ephemeral: true, + }), + ); + }); + + it('does not create a task when /goal has no active task', async () => { + 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.startGoal).not.toHaveBeenCalled(); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('active Roomote task'), + ephemeral: true, + }), + ); + }); + 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/goal-command.ts b/apps/api/src/handlers/discord/goal-command.ts new file mode 100644 index 000000000..7ff045b10 --- /dev/null +++ b/apps/api/src/handlers/discord/goal-command.ts @@ -0,0 +1,69 @@ +import { sendMessageToTask } from '../tasks/sendMessageToTask.js'; +import { prepareTaskGoalActivation } from '@roomote/db/server'; +import { + DEFAULT_TASK_GOAL_MAX_CONTINUATIONS, + type TaskGoal, +} from '@roomote/types'; + +export async function startDiscordTaskGoal(input: { + taskId: string; + userId: string; + objective: string; + clientMessageId: string; +}): Promise<{ success: true } | { success: false; error: string }> { + const goal = { + objective: input.objective, + maxContinuations: DEFAULT_TASK_GOAL_MAX_CONTINUATIONS, + }; + const activation = await prepareTaskGoalActivation({ + taskId: input.taskId, + goal, + }); + if (!activation) { + return { + success: false, + error: 'Goal Mode activation is already pending.', + }; + } + + const goalContext: TaskGoal = { + ...goal, + generation: activation.generation, + status: 'active', + continuationsUsed: 0, + blockedReason: null, + completedAt: null, + }; + + try { + const delivered = await sendMessageToTask({ + taskId: input.taskId, + userId: input.userId, + message: input.objective, + source: 'discord', + clientMessageId: input.clientMessageId, + goalContext, + }); + if (!delivered.success) { + await activation.rollback(); + return { success: false, error: delivered.error }; + } + } catch (error) { + await activation.rollback().catch(() => undefined); + throw error; + } + + let committed: TaskGoal | null; + try { + committed = await activation.commit(); + } catch (error) { + await activation.rollback().catch(() => undefined); + throw error; + } + if (!committed) { + await activation.rollback(); + return { success: false, error: 'Goal Mode activation was superseded.' }; + } + + return { success: true }; +} diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index fa0eecf9b..a587d3f5d 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -93,6 +93,7 @@ import { resolveDiscordChannelContext, } from './task-launch.js'; import { startNewDiscordTask } from './task-orchestration.js'; +import { startDiscordTaskGoal } from './goal-command.js'; import { buildDiscordContinuationPrompt, fetchDiscordThreadHistoryBestEffort, @@ -154,6 +155,7 @@ const DISCORD_HELP_MESSAGE = [ '', '**Available commands**', '`/new request:` — start a fresh task.', + '`/goal objective:` — keep working toward an objective across multiple turns.', '`/link code:` — link this Discord account in a DM with me.', '`/help` — show this message.', '', @@ -509,7 +511,11 @@ async function processDiscordGatewayEvent( } if (command && command.name !== 'new') { - return { ok: true, ignored: 'unsupported_command' }; + if (command.name === 'goal') { + // Handled after resolving the current conversation and linked user. + } else { + return { ok: true, ignored: 'unsupported_command' }; + } } if (command?.name === 'new' && !command.request) { await replyToDiscordEvent({ @@ -683,6 +689,47 @@ async function processDiscordGatewayEvent( userId: senderUserId, }); + if (command?.name === 'goal') { + if (!command.objective) { + await replyToDiscordEvent({ + provider: resolved.provider, + applicationId: resolved.applicationId, + channel, + interaction: interactionReplyContext(event), + text: 'Add what you want Roomote to keep working toward in the `objective` field.', + ephemeral: true, + }); + return { ok: true, goalStarted: false, reason: 'missing_objective' }; + } + if (!activeRun) { + await replyToDiscordEvent({ + provider: resolved.provider, + applicationId: resolved.applicationId, + channel, + interaction: interactionReplyContext(event), + text: 'Use `/goal` in an active Roomote task thread or DM. Start a task with `/new` or mention me first.', + ephemeral: true, + }); + return { ok: true, goalStarted: false, reason: 'no_active_task' }; + } + + const result = await startDiscordTaskGoal({ + taskId: activeRun.taskId, + userId: senderUserId, + objective: command.objective, + clientMessageId: interaction?.id ?? event.eventId, + }); + await replyToDiscordEvent({ + provider: resolved.provider, + applicationId: resolved.applicationId, + channel, + interaction: interactionReplyContext(event), + text: result.success ? 'Goal Mode enabled.' : result.error, + ephemeral: true, + }); + return { ok: true, goalStarted: result.success, runId: activeRun.id }; + } + const messageAttachments = message ? getDiscordMessageAttachments(message) : []; diff --git a/apps/api/src/handlers/tasks/sendMessageToTask.ts b/apps/api/src/handlers/tasks/sendMessageToTask.ts index e9959b1ff..02a777f42 100644 --- a/apps/api/src/handlers/tasks/sendMessageToTask.ts +++ b/apps/api/src/handlers/tasks/sendMessageToTask.ts @@ -15,6 +15,7 @@ import type { TaskPayload, RunTokenContext, PullRequestStatus, + TaskGoal, } from '@roomote/types'; import { trackLatestUserMessageForReplyQuote } from '@roomote/communication/messages'; import { @@ -728,6 +729,7 @@ export async function sendMessageToTask({ clientMessageId, senderMode, workerQuoteUserName, + goalContext, }: { taskId: string; userId: string; @@ -746,6 +748,7 @@ export async function sendMessageToTask({ * commenter has no linked account. */ workerQuoteUserName?: string; + goalContext?: TaskGoal; }): Promise { try { const run = await findLatestTaskRun(taskId, { @@ -787,6 +790,13 @@ export async function sendMessageToTask({ } if (isExitedRunStatus(run.status)) { + if (goalContext) { + return { + success: false, + error: `Task is not active (status: ${run.status})`, + status: 409, + }; + } const resumeResult = await resumeTaskFromSnapshot({ taskId, userId: linkedReviewHandoff.senderUserId, @@ -878,6 +888,7 @@ export async function sendMessageToTask({ // credential identity changes. Native steering injects at the // next step; fallback steering aborts and replays promptly. ...(requiresActorHandoff ? { autoSteerWhenQueued: true } : {}), + ...(goalContext ? { autoSteerWhenQueued: true, goalContext } : {}), ...(images?.length ? { images } : {}), }), }); diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index a0e94264a..71078c633 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`, `/link`, and `/help` commands automatically. You do not need to +the `/new`, `/goal`, `/link`, and `/help` commands automatically. You do not need to copy an application ID or bot name into Roomote. @@ -111,6 +111,8 @@ under **Settings > Automations**, the same way you would pick a Slack channel. - send the bot a direct message - use `/new request:` to force a fresh task instead of continuing the current one +- use `/goal objective:` to keep working toward an objective across + multiple turns in an active task thread or DM; this does not create a new task - when Roomote asks where to run a task, use a button or reply naturally in the same thread or DM; `yes`, `never mind`, and `use API instead` confirm, cancel, or revise the pending route diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index d47fb924f..4243a3211 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -574,7 +574,7 @@ export function CommsProviderSection({ !provider.runtimeSatisfied && provider.id === 'telegram' ? 'Roomote generates a webhook secret automatically, registers the webhook when you save, and defaults Telegram task launches to the admin who saves this configuration.' : !provider.runtimeSatisfied && provider.id === 'discord' - ? 'Roomote validates the token, derives the bot identity, and registers /new, /link, and /help when you save.' + ? 'Roomote validates the token, derives the bot identity, and registers /new, /goal, /link, and /help when you save.' : undefined } onCreateSlackApp={(configToken) => diff --git a/apps/web/src/components/settings/DiscordSetupStatus.test.tsx b/apps/web/src/components/settings/DiscordSetupStatus.test.tsx index ce674161e..5dd7d1d5f 100644 --- a/apps/web/src/components/settings/DiscordSetupStatus.test.tsx +++ b/apps/web/src/components/settings/DiscordSetupStatus.test.tsx @@ -212,7 +212,9 @@ describe('DiscordSetupStatus', () => { expect(screen.getByText(/Connected as @roomote/)).toBeInTheDocument(); expect(screen.getByText(/receiving Discord events/)).toBeInTheDocument(); - expect(screen.getByText(/\/new, \/link, and \/help/)).toBeInTheDocument(); + expect( + screen.getByText(/\/new, \/goal, \/link, and \/help/), + ).toBeInTheDocument(); expect( screen.getByRole('link', { name: /Add to Discord/i }), ).toHaveAttribute( diff --git a/apps/web/src/components/settings/DiscordSetupStatus.tsx b/apps/web/src/components/settings/DiscordSetupStatus.tsx index e84d7928a..857444df8 100644 --- a/apps/web/src/components/settings/DiscordSetupStatus.tsx +++ b/apps/web/src/components/settings/DiscordSetupStatus.tsx @@ -113,7 +113,7 @@ export function DiscordSetupStatus({ status }: { status: DiscordCommsStatus }) { label="Slash commands" detail={ commandsReady - ? '/new, /link, and /help are registered.' + ? '/new, /goal, /link, and /help are registered.' : status.commands.status === 'missing' ? 'One or more Roomote commands are missing.' : 'Roomote could not verify command registration.' diff --git a/apps/web/src/trpc/commands/comms/index.ts b/apps/web/src/trpc/commands/comms/index.ts index 57bf7d67f..18a4d1c76 100644 --- a/apps/web/src/trpc/commands/comms/index.ts +++ b/apps/web/src/trpc/commands/comms/index.ts @@ -228,7 +228,7 @@ export type DiscordCommsStatus = { }; const DISCORD_GATEWAY_STATUS_KEY = 'discord:gateway:status'; -const DISCORD_REQUIRED_COMMANDS = ['help', 'link', 'new'] as const; +const DISCORD_REQUIRED_COMMANDS = ['goal', 'help', 'link', 'new'] as const; const DISCORD_APPLICATION_MESSAGE_CONTENT_FLAGS = (1 << 18) | (1 << 19); const DISCORD_API_TIMEOUT_MS = 5_000; diff --git a/packages/communication/src/__tests__/discord-event.test.ts b/packages/communication/src/__tests__/discord-event.test.ts index 251ef7c21..87a993b59 100644 --- a/packages/communication/src/__tests__/discord-event.test.ts +++ b/packages/communication/src/__tests__/discord-event.test.ts @@ -314,6 +314,34 @@ describe('Discord Gateway event normalization', () => { expect(discordEventToQueuedCommunicationMessage(botEvent)).toBeNull(); expect(discordEventToQueuedCommunicationMessage(help)).toBeNull(); }); + + it('parses goal commands without queueing them as ordinary messages', () => { + const event = parse({ + op: 0, + t: 'INTERACTION_CREATE', + d: { + id: 'interaction-goal', + application_id: 'application-1', + type: 2, + token: 'token', + channel_id: 'channel-1', + user: { id: 'user-1', username: 'matt' }, + data: { + name: 'GOAL', + options: [ + { name: 'objective', type: 3, value: ' Ship the release ' }, + ], + }, + }, + }); + + expect(getDiscordInteractionCommand(event)).toEqual({ + name: 'goal', + objective: 'Ship the release', + }); + expect(isDiscordTaskEntryEvent(event)).toBe(true); + expect(discordEventToQueuedCommunicationMessage(event)).toBeNull(); + }); }); describe('component interaction envelopes', () => { diff --git a/packages/communication/src/__tests__/discord-provider.test.ts b/packages/communication/src/__tests__/discord-provider.test.ts index 677629438..c3889aafe 100644 --- a/packages/communication/src/__tests__/discord-provider.test.ts +++ b/packages/communication/src/__tests__/discord-provider.test.ts @@ -222,6 +222,18 @@ describe('DiscordCommunicationProvider', () => { options: [expect.objectContaining({ name: 'request', required: true })], }, { name: 'link', type: 1 }, + { + name: 'goal', + type: 1, + description: 'Keep working toward an objective across multiple turns', + options: [ + expect.objectContaining({ + name: 'objective', + required: true, + max_length: 6_000, + }), + ], + }, { name: 'help', type: 1 }, ]); }); diff --git a/packages/communication/src/discord-event.ts b/packages/communication/src/discord-event.ts index efd363093..c5d31c5e4 100644 --- a/packages/communication/src/discord-event.ts +++ b/packages/communication/src/discord-event.ts @@ -533,7 +533,12 @@ function findInteractionOption( export function getDiscordInteractionCommand( eventOrInteraction: DiscordGatewayEvent | DiscordInteraction, -): { name: string; request?: string; code?: string } | null { +): { + name: string; + request?: string; + code?: string; + objective?: string; +} | null { const interaction = isDiscordGatewayEventValue(eventOrInteraction) ? getDiscordInteractionCreate(eventOrInteraction) : eventOrInteraction; @@ -545,12 +550,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 +585,8 @@ export function isDiscordTaskEntryEvent( isDiscordBotMentioned(message, options.botUserId)) ); } - return getDiscordInteractionCommand(event)?.name === 'new'; + const commandName = getDiscordInteractionCommand(event)?.name; + return commandName === 'new' || commandName === 'goal'; } function formatDiscordUser(input: { diff --git a/packages/communication/src/discord-provider.ts b/packages/communication/src/discord-provider.ts index 372c21ba7..48fcd309c 100644 --- a/packages/communication/src/discord-provider.ts +++ b/packages/communication/src/discord-provider.ts @@ -1217,6 +1217,20 @@ export class DiscordCommunicationProvider implements CommunicationProviderAdapte }, ], }, + { + name: 'goal', + description: 'Keep working toward an objective across multiple turns', + type: 1, + options: [ + { + type: 3, + name: 'objective', + description: 'What should the current task keep working toward?', + required: true, + max_length: 6_000, + }, + ], + }, { name: 'help', description: 'Show Roomote command help', type: 1 }, ], { retryNetworkErrors: true, retryServerErrors: true },