From d89806f107a0ba5e328a321e42c64a5607850bf7 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:17:15 +0000 Subject: [PATCH 1/2] feat: add Goal Mode across task surfaces --- .changeset/goal-mode-surfaces.md | 5 + apps/api/src/handlers/discord/goal-command.ts | 63 +------ .../message-entry-removed-commands.test.ts | 62 ++++++- .../handlers/slack/events/message-entry.ts | 65 ++++++- .../src/handlers/slack/goal-command.test.ts | 174 ++++++++++++++++++ apps/api/src/handlers/slack/goal-command.ts | 87 +++++++++ apps/api/src/handlers/tasks/startTaskGoal.ts | 71 +++++++ apps/docs/goal-mode.mdx | 31 +++- .../docs/providers/communications/discord.mdx | 1 + apps/docs/providers/communications/slack.mdx | 16 ++ .../(authenticated)/home/Home.client.test.tsx | 69 ++++++- .../web/src/app/(authenticated)/home/Home.tsx | 26 ++- .../task-runs/useCreateStandardTaskRun.ts | 1 + .../src/trpc/commands/task-runs/index.test.ts | 27 +++ apps/web/src/trpc/commands/task-runs/index.ts | 3 + apps/web/src/trpc/routers/_app.ts | 1 + .../find-active-slack-task-run.test.ts | 43 ++++- .../slack/src/find-active-slack-task-run.ts | 31 +++- 18 files changed, 695 insertions(+), 81 deletions(-) create mode 100644 .changeset/goal-mode-surfaces.md create mode 100644 apps/api/src/handlers/slack/goal-command.test.ts create mode 100644 apps/api/src/handlers/slack/goal-command.ts create mode 100644 apps/api/src/handlers/tasks/startTaskGoal.ts diff --git a/.changeset/goal-mode-surfaces.md b/.changeset/goal-mode-surfaces.md new file mode 100644 index 000000000..0bbeadf90 --- /dev/null +++ b/.changeset/goal-mode-surfaces.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': minor +--- + +Start Goal Mode from the web task launcher and active Slack task conversations, with cross-surface usage documentation. diff --git a/apps/api/src/handlers/discord/goal-command.ts b/apps/api/src/handlers/discord/goal-command.ts index 7ff045b10..e5aefd2d8 100644 --- a/apps/api/src/handlers/discord/goal-command.ts +++ b/apps/api/src/handlers/discord/goal-command.ts @@ -1,9 +1,4 @@ -import { sendMessageToTask } from '../tasks/sendMessageToTask.js'; -import { prepareTaskGoalActivation } from '@roomote/db/server'; -import { - DEFAULT_TASK_GOAL_MAX_CONTINUATIONS, - type TaskGoal, -} from '@roomote/types'; +import { startTaskGoal } from '../tasks/startTaskGoal.js'; export async function startDiscordTaskGoal(input: { taskId: string; @@ -11,59 +6,5 @@ export async function startDiscordTaskGoal(input: { 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 }; + return startTaskGoal({ ...input, source: 'discord' }); } diff --git a/apps/api/src/handlers/slack/events/message-entry-removed-commands.test.ts b/apps/api/src/handlers/slack/events/message-entry-removed-commands.test.ts index b0eee771b..632224015 100644 --- a/apps/api/src/handlers/slack/events/message-entry-removed-commands.test.ts +++ b/apps/api/src/handlers/slack/events/message-entry-removed-commands.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { isRemovedEvalCommandInvocation } from './message-entry.js'; +import { + getSlackGoalCommandForEvent, + isRemovedEvalCommandInvocation, +} from './message-entry.js'; describe('removed Slack commands', () => { it.each([ @@ -19,3 +22,60 @@ describe('removed Slack commands', () => { }, ); }); + +describe('Slack Goal Mode command routing', () => { + it('accepts a bot-mentioned command in a channel thread', () => { + expect( + getSlackGoalCommandForEvent({ + type: 'app_mention', + channel: 'C123', + channel_type: 'channel', + thread_ts: '100.000', + user: 'U123', + ts: '101.000', + text: '<@UBOT> goal Ship the release', + }), + ).toEqual({ objective: 'Ship the release' }); + }); + + it('accepts an unmentioned command in a direct message', () => { + expect( + getSlackGoalCommandForEvent({ + type: 'message', + channel: 'D123', + channel_type: 'im', + user: 'U123', + ts: '101.000', + text: 'goal Ship the release', + }), + ).toEqual({ objective: 'Ship the release' }); + }); + + it('does not treat unmentioned channel text as a command', () => { + expect( + getSlackGoalCommandForEvent({ + type: 'message', + channel: 'C123', + channel_type: 'channel', + thread_ts: '100.000', + user: 'U123', + ts: '101.000', + text: 'goal Ship the release', + }), + ).toBeNull(); + }); + + it('waits for the app_mention event instead of handling the duplicate channel message', () => { + expect( + getSlackGoalCommandForEvent({ + type: 'message', + channel: 'C123', + channel_type: 'channel', + thread_ts: '100.000', + user: 'U123', + ts: '101.000', + text: '<@UBOT> goal Ship the release', + }), + ).toBeNull(); + }); +}); diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index 4dd8b2e7d..b16fb0770 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -88,6 +88,12 @@ import { } from '../helpers/mention-routing.js'; import { postSlackThreadMarkdownMessage } from '../helpers/thread-posting.js'; import { lookupSlackUserMapping } from '../helpers/user-mapping.js'; +import { + findSlackGoalCommandTask, + parseSlackGoalCommand, + processSlackGoalCommand, + type SlackGoalCommand, +} from '../goal-command.js'; import { compareNumericMessageIds, evaluateUnmentionedThreadReplyRouting, @@ -104,6 +110,21 @@ export function isRemovedEvalCommandInvocation(text: string): boolean { return REMOVED_EVAL_COMMAND_PATTERN.test(mentionStrippedText); } +export function getSlackGoalCommandForEvent( + event: SlackEvent, +): SlackGoalCommand | null { + const command = parseSlackGoalCommand(event.text); + + if ( + !command || + (event.channel_type !== 'im' && event.type !== 'app_mention') + ) { + return null; + } + + return command; +} + async function postRemovedEvalCommandMessage(params: { event: SlackEvent; slack: SlackNotifier; @@ -1541,6 +1562,7 @@ async function handleSlackEntryEvent(params: { completionEmoji: string; skipThreadFollowupHandling?: boolean; prefetchedThreadMessages?: SlackThreadMessage[]; + goalCommand?: SlackGoalCommand | null; }): Promise { const { event, @@ -1551,6 +1573,7 @@ async function handleSlackEntryEvent(params: { completionEmoji, skipThreadFollowupHandling = false, prefetchedThreadMessages, + goalCommand = null, } = params; if (!event.user) { @@ -1599,7 +1622,9 @@ async function handleSlackEntryEvent(params: { const activeRun = skipThreadFollowupHandling ? null - : await findActiveSlackTaskRun(threadId, { slackTeamId: teamId }); + : goalCommand + ? await findSlackGoalCommandTask(event, teamId) + : await findActiveSlackTaskRun(threadId, { slackTeamId: teamId }); const shouldRecordThreadReply = Boolean(event.thread_ts) && !skipThreadFollowupHandling && @@ -1621,6 +1646,24 @@ async function handleSlackEntryEvent(params: { activeTaskId: activeRun?.taskId, }); + if (goalCommand) { + void processSlackGoalCommand({ + event, + slack, + teamId, + userId: userMapping.userId, + taskId: activeRun?.taskId ?? null, + threadTs: activeRun?.slackThreadTs ?? threadId, + command: goalCommand, + }).catch((error) => { + console.error( + `Failed to process Slack Goal Mode command in thread ${threadId}:`, + error instanceof Error ? error.message : String(error), + ); + }); + return; + } + if (!skipThreadFollowupHandling) { const followUpRoute = await resolveSlackThreadFollowUpRoute({ threadId, @@ -1860,16 +1903,19 @@ export async function handleMessageOrAppMentionEvent(params: { ) ? event : null; + const goalCommand = getSlackGoalCommandForEvent(event); const unmentionedThreadReplyRouting: UnmentionedSlackThreadReplyRoutingDecision = - event.type === 'message' && event.channel_type !== 'im' - ? await shouldRouteUnmentionedSlackThreadReplyToAgent({ - event, - slack: context.slack, - slackInstallation: context.slackInstallation, - teamId: context.teamId, - }) - : { shouldRoute: false }; + goalCommand + ? { shouldRoute: true, threadMessages: [] } + : event.type === 'message' && event.channel_type !== 'im' + ? await shouldRouteUnmentionedSlackThreadReplyToAgent({ + event, + slack: context.slack, + slackInstallation: context.slackInstallation, + teamId: context.teamId, + }) + : { shouldRoute: false }; const isBotMentionedMessageEvent = event.type === 'message' && mentionsSlackBot(event, context.slackInstallation.botUserId); @@ -1927,5 +1973,6 @@ export async function handleMessageOrAppMentionEvent(params: { prefetchedThreadMessages: unmentionedThreadReplyRouting.shouldRoute ? unmentionedThreadReplyRouting.threadMessages : undefined, + goalCommand, }); } diff --git a/apps/api/src/handlers/slack/goal-command.test.ts b/apps/api/src/handlers/slack/goal-command.test.ts new file mode 100644 index 000000000..61e77900e --- /dev/null +++ b/apps/api/src/handlers/slack/goal-command.test.ts @@ -0,0 +1,174 @@ +const mocks = vi.hoisted(() => ({ + findByChannel: vi.fn(), + findByThread: vi.fn(), + postMessage: vi.fn(), + startGoal: vi.fn(), +})); + +vi.mock('@roomote/slack', async (importOriginal) => ({ + ...(await importOriginal()), + findActiveSlackTaskRun: mocks.findByThread, + findActiveSlackTaskRunByChannel: mocks.findByChannel, +})); + +vi.mock('../tasks/startTaskGoal.js', () => ({ + startTaskGoal: mocks.startGoal, +})); + +vi.mock('./helpers/thread-posting.js', () => ({ + postSlackThreadMarkdownMessage: mocks.postMessage, +})); + +import { + findSlackGoalCommandTask, + parseSlackGoalCommand, + processSlackGoalCommand, +} from './goal-command.js'; + +describe('Slack Goal Mode command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.startGoal.mockResolvedValue({ success: true }); + mocks.postMessage.mockResolvedValue(true); + mocks.findByChannel.mockResolvedValue(null); + mocks.findByThread.mockResolvedValue(null); + }); + + it.each([ + ['goal Ship the release', 'Ship the release'], + ['/goal Ship the release', 'Ship the release'], + ['<@U123> goal Ship the release', 'Ship the release'], + ['<@U123>: /GOAL Ship the release ', 'Ship the release'], + ])('parses %s', (text, objective) => { + expect(parseSlackGoalCommand(text)).toEqual({ objective }); + }); + + it.each(['goalkeeper notes', 'set a goal for this', '<@U123> continue'])( + 'does not intercept %s', + (text) => { + expect(parseSlackGoalCommand(text)).toBeNull(); + }, + ); + + it('starts Goal Mode with Slack as the source and replies in the task thread', async () => { + await processSlackGoalCommand({ + event: { + type: 'app_mention', + channel: 'C123', + thread_ts: '100.000', + user: 'U123', + ts: '101.000', + text: '<@UBOT> goal Ship the release', + }, + slack: {} as never, + teamId: 'T123', + userId: 'user-1', + taskId: 'task-1', + threadTs: '100.000', + command: { objective: 'Ship the release' }, + }); + + expect(mocks.startGoal).toHaveBeenCalledWith({ + taskId: 'task-1', + userId: 'user-1', + objective: 'Ship the release', + source: 'slack', + clientMessageId: '101.000', + }); + expect(mocks.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'C123', + threadTs: '100.000', + text: 'Goal Mode enabled.', + }), + ); + }); + + it('finds a later top-level DM command by channel and keeps the original task thread', async () => { + mocks.findByChannel.mockResolvedValue({ + taskId: 'task-1', + slackThreadTs: '100.000', + }); + const event = { + type: 'message', + channel: 'D123', + channel_type: 'im', + user: 'U123', + ts: '101.000', + text: 'goal Ship the release', + }; + + const activeRun = await findSlackGoalCommandTask(event, 'T123'); + await processSlackGoalCommand({ + event, + slack: {} as never, + teamId: 'T123', + userId: 'user-1', + taskId: activeRun?.taskId ?? null, + threadTs: activeRun?.slackThreadTs ?? event.ts, + command: { objective: 'Ship the release' }, + }); + + expect(mocks.findByChannel).toHaveBeenCalledWith('D123', { + slackTeamId: 'T123', + }); + expect(mocks.findByThread).not.toHaveBeenCalled(); + expect(mocks.startGoal).toHaveBeenCalledOnce(); + expect(mocks.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ threadTs: '100.000' }), + ); + }); + + it('explains that Goal Mode needs an active task', async () => { + await processSlackGoalCommand({ + event: { + type: 'message', + channel: 'D123', + channel_type: 'im', + user: 'U123', + ts: '101.000', + text: 'goal Ship the release', + }, + slack: {} as never, + teamId: 'T123', + userId: 'user-1', + taskId: null, + threadTs: '101.000', + command: { objective: 'Ship the release' }, + }); + + expect(mocks.startGoal).not.toHaveBeenCalled(); + expect(mocks.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('active task thread or DM'), + }), + ); + }); + + it('rejects attachments on a Goal Mode command', async () => { + await processSlackGoalCommand({ + event: { + type: 'message', + channel: 'D123', + channel_type: 'im', + user: 'U123', + ts: '101.000', + text: 'goal Ship the release', + files: [{ id: 'F123' } as never], + }, + slack: {} as never, + teamId: 'T123', + userId: 'user-1', + taskId: 'task-1', + threadTs: '101.000', + command: { objective: 'Ship the release' }, + }); + + expect(mocks.startGoal).not.toHaveBeenCalled(); + expect(mocks.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Goal Mode does not support attachments.', + }), + ); + }); +}); diff --git a/apps/api/src/handlers/slack/goal-command.ts b/apps/api/src/handlers/slack/goal-command.ts new file mode 100644 index 000000000..13755e711 --- /dev/null +++ b/apps/api/src/handlers/slack/goal-command.ts @@ -0,0 +1,87 @@ +import { stripLeadingRawSlackMention } from '@roomote/cloud-agents'; +import { + findActiveSlackTaskRun, + findActiveSlackTaskRunByChannel, + type SlackEvent, + type SlackNotifier, +} from '@roomote/slack'; + +import { startTaskGoal } from '../tasks/startTaskGoal.js'; +import { postSlackThreadMarkdownMessage } from './helpers/thread-posting.js'; + +const SLACK_GOAL_COMMAND_PATTERN = /^\/?goal(?:\s+([\s\S]*))?$/iu; + +export type SlackGoalCommand = { objective: string }; + +export function parseSlackGoalCommand(text: string): SlackGoalCommand | null { + const commandText = stripLeadingRawSlackMention(text).trim(); + const match = SLACK_GOAL_COMMAND_PATTERN.exec(commandText); + + return match ? { objective: (match[1] ?? '').trim() } : null; +} + +export async function findSlackGoalCommandTask( + event: SlackEvent, + teamId: string, +) { + if (event.channel_type === 'im' && !event.thread_ts) { + return findActiveSlackTaskRunByChannel(event.channel, { + slackTeamId: teamId, + }); + } + + return findActiveSlackTaskRun(event.thread_ts || event.ts, { + slackTeamId: teamId, + }); +} + +export async function processSlackGoalCommand(input: { + event: SlackEvent; + slack: SlackNotifier; + teamId: string; + userId: string; + taskId: string | null; + threadTs: string; + command: SlackGoalCommand; +}): Promise { + let responseText: string; + + if (!input.command.objective) { + responseText = 'Add what you want me to keep working toward after `goal`.'; + } else if ((input.event.files?.length ?? 0) > 0) { + responseText = 'Goal Mode does not support attachments.'; + } else if (!input.taskId) { + responseText = + 'Use `goal ` in an active task thread or DM. Start a task by mentioning me first.'; + } else { + try { + const result = await startTaskGoal({ + taskId: input.taskId, + userId: input.userId, + objective: input.command.objective, + source: 'slack', + clientMessageId: input.event.ts, + }); + responseText = result.success ? 'Goal Mode enabled.' : result.error; + } catch (error) { + console.error( + `Failed to enable Goal Mode for Slack task ${input.taskId}:`, + error instanceof Error ? error.message : String(error), + ); + responseText = 'I could not enable Goal Mode. Try again in a moment.'; + } + } + + await postSlackThreadMarkdownMessage({ + slack: input.slack, + channel: input.event.channel, + threadTs: input.threadTs, + text: responseText, + sourceMessageTs: input.event.ts, + conversationLog: { + userId: input.userId, + slackTeamId: input.teamId, + source: 'slack_goal_command', + }, + }); +} diff --git a/apps/api/src/handlers/tasks/startTaskGoal.ts b/apps/api/src/handlers/tasks/startTaskGoal.ts new file mode 100644 index 000000000..715be6d00 --- /dev/null +++ b/apps/api/src/handlers/tasks/startTaskGoal.ts @@ -0,0 +1,71 @@ +import { prepareTaskGoalActivation } from '@roomote/db/server'; +import { + DEFAULT_TASK_GOAL_MAX_CONTINUATIONS, + type TaskGoal, +} from '@roomote/types'; + +import { sendMessageToTask } from './sendMessageToTask.js'; + +export async function startTaskGoal(input: { + taskId: string; + userId: string; + objective: string; + source: 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: input.source, + 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/docs/goal-mode.mdx b/apps/docs/goal-mode.mdx index 264820762..0ba1fcb9e 100644 --- a/apps/docs/goal-mode.mdx +++ b/apps/docs/goal-mode.mdx @@ -15,19 +15,40 @@ request that should finish in one turn, send an ordinary task message instead. ## Start Goal Mode -Goal Mode starts on an existing task. The command does not create a new task. +You can start a new web task in Goal Mode or activate Goal Mode on an existing +task. Activating it on an existing task keeps the current workspace and context. -### From the web task view +### From the web dashboard -Enter `/goal` followed by the objective in the task composer: +Enter `/goal` followed by the objective in the new-task input or an active task +composer: ```text /goal Reduce p95 checkout latency below 120 ms, verified by the checkout benchmark, while keeping the correctness suite green ``` -You can also open the command picker from the composer and select `/goal`. +From the new-task input, the command creates the task in Goal Mode immediately. +From an active task, it sets or replaces the objective without creating a new +task. You can also open the command picker from the active task composer and +select `/goal`. + Describe the complete outcome after the command. Goal Mode does not accept file -attachments, so send any supporting files to the task before starting the goal. +attachments, so send supporting files in a follow-up message. + +### From Slack + +In an active Roomote task thread, mention the installed Slack app: + +```text +@Roomote goal Reduce p95 checkout latency below 120 ms while keeping the correctness suite green +``` + +Replace `Roomote` with your Slack app's installed display name. In an active +direct-message task conversation, send `goal ` without the mention. +Slack uses a message command instead of a native slash command so the request +stays attached to the exact task thread. If there is no active task, mention the +app with a normal request first. See +[Slack](/providers/communications/slack) for the complete task workflow. ### From Discord diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 71078c633..c542a72c6 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -113,6 +113,7 @@ under **Settings > Automations**, the same way you would pick a Slack channel. 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 + (see [Goal Mode](/goal-mode) for cross-surface usage) - 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/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 622eedfb6..c7e464ccb 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -149,6 +149,20 @@ If you want to start tasks by direct message, make sure the app can receive DMs. Enable the app surfaces needed for messages to the bot, then keep the `message.im` event subscription enabled. +## Start Goal Mode + +In an active Roomote task thread, send: + +```text +@Roomote goal +``` + +Replace `Roomote` with your Slack app's installed display name. In an active +direct-message task conversation, send `goal ` without the mention. +Slack uses this message command instead of a native slash command so Roomote can +identify the exact task thread. See [Goal Mode](/goal-mode) for examples and +cross-surface usage. + ## Local URL changes Keep the public URL stable. When it changes, update the Slack app's redirect @@ -162,3 +176,5 @@ restart Roomote with the matching URL. 3. mention the app in a channel or send it a direct message 4. confirm Roomote starts a task and replies with a task link 5. reply in the same thread and confirm the message continues the same task +6. send `@Roomote goal ` in the thread and confirm Roomote replies + with **Goal Mode enabled.** diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index 143a68e98..364b0b956 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -19,6 +19,8 @@ let currentEnvironments: Array<{ id: string; name: string }> | undefined = [ { id: 'env-2', name: 'Secondary Env' }, ]; let currentEnvironmentsPending = false; +let currentPromptText = 'Test prompt'; +let currentPromptFiles: PromptInputMessage['files'] = []; const { mockPush, @@ -228,8 +230,11 @@ vi.mock('@/components/tasks', async () => { if (submitDisabledReason) { return; } - onPromptTextChange?.('Test prompt'); - const result = onSubmit({ text: 'Test prompt', files: [] }); + onPromptTextChange?.(currentPromptText); + const result = onSubmit({ + text: currentPromptText, + files: currentPromptFiles, + }); if (result instanceof Promise) { void result.catch(() => {}); @@ -321,6 +326,8 @@ describe('Home', () => { { id: 'env-2', name: 'Secondary Env' }, ]; currentEnvironmentsPending = false; + currentPromptText = 'Test prompt'; + currentPromptFiles = []; localStorage.clear(); vi.clearAllMocks(); @@ -654,6 +661,64 @@ describe('Home', () => { expect(mockRouteHomeTask).not.toHaveBeenCalled(); }); + it('creates an initial Goal Mode task from the new-task input', async () => { + currentPromptText = '/goal Ship the release'; + mockRouteHomeTask.mockResolvedValue(routedEnvironmentSuggestion); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Submit prompt' })); + + await waitFor(() => { + expect(mockRouteHomeTask).toHaveBeenCalledWith({ + description: 'Ship the release', + }); + expect(mockCreateStandardTaskRun).toHaveBeenCalledWith( + expect.objectContaining({ + goal: { objective: 'Ship the release' }, + payload: expect.objectContaining({ + description: 'Ship the release', + }), + }), + ); + }); + }); + + it('rejects an empty Goal Mode command from the new-task input', async () => { + currentPromptText = '/goal'; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Submit prompt' })); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + 'Describe the goal after /goal.', + ); + }); + expect(mockRouteHomeTask).not.toHaveBeenCalled(); + expect(mockCreateStandardTaskRun).not.toHaveBeenCalled(); + }); + + it('rejects attachments on an initial Goal Mode command', async () => { + currentPromptText = '/goal Ship the release'; + currentPromptFiles = [ + {} as NonNullable[number], + ]; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Submit prompt' })); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + 'Goal Mode does not support attachments.', + ); + }); + expect(mockRouteHomeTask).not.toHaveBeenCalled(); + expect(mockCreateStandardTaskRun).not.toHaveBeenCalled(); + }); + it('uses opencode as the default harness', async () => { render(); diff --git a/apps/web/src/app/(authenticated)/home/Home.tsx b/apps/web/src/app/(authenticated)/home/Home.tsx index 0080d888e..11c1e1de3 100644 --- a/apps/web/src/app/(authenticated)/home/Home.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.tsx @@ -100,10 +100,13 @@ type RoutingFlowState = 'idle' | 'routing_pending' | 'launching'; type SubmissionSnapshot = { branch?: string; description?: string; + goal?: { objective: string }; images?: string[]; blank: boolean; }; +const GOAL_COMMAND_PATTERN = /^\/goal(?:\s+([\s\S]*))?$/i; + const DEFAULT_FORM_VALUES: CreateTaskFormValues = { repository: AUTO_WORKSPACE_VALUE, branch: '', @@ -427,6 +430,7 @@ export function Home({ description?: string; images?: string[]; modelId?: string; + goal?: { objective: string }; blank: boolean; }): Promise => { try { @@ -434,6 +438,7 @@ export function Home({ harness: DEFAULT_LAUNCH_CODING_HARNESS, model: payload.modelId ?? selectedModelId, computeProvider: selectedComputeProvider, + goal: payload.goal, payload, }); @@ -561,6 +566,7 @@ export function Home({ ? routedResult.result.workspace.id : undefined, description: submission.description, + goal: submission.goal, images: submission.images, modelId: routedModelId, blank: submission.blank, @@ -592,9 +598,25 @@ export function Home({ } const text = message.text.trim(); + const goalCommandMatch = GOAL_COMMAND_PATTERN.exec(text); + const goalObjective = goalCommandMatch + ? (goalCommandMatch[1] ?? '').trim() + : null; + + if ( + goalObjective !== null && + (!goalObjective || message.files.length > 0) + ) { + toast.error( + message.files.length > 0 + ? 'Goal Mode does not support attachments.' + : 'Describe the goal after /goal.', + ); + return; + } const preparedPrompt = await preparePromptAttachments({ - text, + text: goalObjective ?? text, attachments: message.files, }); @@ -602,6 +624,7 @@ export function Home({ branch: canSelectBranch ? branch : undefined, description: preparedPrompt.text.length > 0 ? preparedPrompt.text : undefined, + goal: goalObjective === null ? undefined : { objective: goalObjective }, images: preparedPrompt.images, blank: preparedPrompt.text.length === 0, }; @@ -616,6 +639,7 @@ export function Home({ branch: environmentId ? undefined : submission.branch, environmentId, description: submission.description, + goal: submission.goal, images: submission.images, blank: submission.blank, }); diff --git a/apps/web/src/hooks/task-runs/useCreateStandardTaskRun.ts b/apps/web/src/hooks/task-runs/useCreateStandardTaskRun.ts index dc8d1cc0c..298878c0b 100644 --- a/apps/web/src/hooks/task-runs/useCreateStandardTaskRun.ts +++ b/apps/web/src/hooks/task-runs/useCreateStandardTaskRun.ts @@ -12,6 +12,7 @@ type ManualTaskRunVariables = { harness?: LaunchCodingHarness; model?: string; computeProvider?: import('@roomote/types').ComputeProvider; + goal?: { objective: string; maxContinuations?: number }; sourceTaskId?: string; sourceArtifactId?: string; sourceArtifactPath?: string; diff --git a/apps/web/src/trpc/commands/task-runs/index.test.ts b/apps/web/src/trpc/commands/task-runs/index.test.ts index 2e2c5ff7a..9105e1cd8 100644 --- a/apps/web/src/trpc/commands/task-runs/index.test.ts +++ b/apps/web/src/trpc/commands/task-runs/index.test.ts @@ -368,6 +368,33 @@ describe('createStandardTaskRunCommand', () => { ); }); + it('forwards an initial Goal Mode objective to the task queue', async () => { + await createStandardTaskRunCommand(auth, { + goal: { + objective: 'Ship the release', + maxContinuations: 5, + }, + payload: { + repo: ALL_REPOSITORIES, + description: 'Ship the release', + }, + }); + + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + goal: { + objective: 'Ship the release', + maxContinuations: 5, + }, + task: expect.objectContaining({ + payload: expect.objectContaining({ + description: 'Ship the release', + }), + }), + }), + ); + }); + it('rejects launches without an environment or repository target', async () => { const result = await createStandardTaskRunCommand(auth, { payload: { diff --git a/apps/web/src/trpc/commands/task-runs/index.ts b/apps/web/src/trpc/commands/task-runs/index.ts index d89feb3c8..607ecc82d 100644 --- a/apps/web/src/trpc/commands/task-runs/index.ts +++ b/apps/web/src/trpc/commands/task-runs/index.ts @@ -6,6 +6,7 @@ import { type LaunchCodingHarness, type StandardTask, type TaskGoal, + type TaskGoalInput, RunStatus, TaskPayloadKind, isExitedRunStatus, @@ -119,6 +120,7 @@ type CreateStandardTaskRunInput = { harness?: LaunchCodingHarness; model?: string; computeProvider?: ComputeProvider; + goal?: TaskGoalInput; sourceTaskId?: string; sourceArtifactId?: string; sourceArtifactPath?: string; @@ -459,6 +461,7 @@ export async function createStandardTaskRunCommand( const launchResult = await enqueueTask({ task, + goal: input.goal, initiator: { kind: 'user', userId: auth.userId }, workflow: 'standard', surface: 'web', diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 1db98ff2d..5a4c6cc9f 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -1035,6 +1035,7 @@ export const appRouter = createRouter({ harness: z.enum(launchCodingHarnesses).optional(), model: z.string().trim().min(1).optional(), computeProvider: z.enum(computeProviders).optional(), + goal: taskGoalInputSchema.optional(), sourceTaskId: z.string().optional(), sourceArtifactId: z.string().uuid().optional(), sourceArtifactPath: z.string().optional(), diff --git a/packages/slack/src/__tests__/find-active-slack-task-run.test.ts b/packages/slack/src/__tests__/find-active-slack-task-run.test.ts index 617604984..2a55643bc 100644 --- a/packages/slack/src/__tests__/find-active-slack-task-run.test.ts +++ b/packages/slack/src/__tests__/find-active-slack-task-run.test.ts @@ -19,6 +19,7 @@ vi.mock('@roomote/db/server', () => { tasks: { deletedAt: 'tasks.deletedAt', id: 'tasks.id', + slackChannelId: 'tasks.slackChannelId', slackThreadTs: 'tasks.slackThreadTs', }, taskRuns: { @@ -52,7 +53,10 @@ vi.mock('@roomote/db/server', () => { import { activeRunStatuses } from '@roomote/types'; -import { findActiveSlackTaskRun } from '../find-active-slack-task-run'; +import { + findActiveSlackTaskRun, + findActiveSlackTaskRunByChannel, +} from '../find-active-slack-task-run'; describe('findActiveSlackTaskRun', () => { beforeEach(() => { @@ -121,3 +125,40 @@ describe('findActiveSlackTaskRun', () => { }); }); }); + +describe('findActiveSlackTaskRunByChannel', () => { + beforeEach(() => { + vi.clearAllMocks(); + limitMock.mockResolvedValue([]); + }); + + it('finds the latest active task for a workspace-scoped DM channel', async () => { + limitMock.mockResolvedValueOnce([ + { + id: 42, + taskId: 'task-42', + status: 'running', + slackThreadTs: '111.000', + }, + ]); + + await expect( + findActiveSlackTaskRunByChannel('D123', { slackTeamId: 'T-first' }), + ).resolves.toMatchObject({ + id: 42, + taskId: 'task-42', + slackThreadTs: '111.000', + }); + expect(whereMock).toHaveBeenCalledWith({ + and: [ + { eq: ['tasks.slackChannelId', 'D123'] }, + expect.objectContaining({ + sql: expect.arrayContaining(['T-first']), + }), + { inArray: ['taskRuns.status', [...activeRunStatuses]] }, + { isNull: 'taskRuns.canceledAt' }, + { isNull: 'tasks.deletedAt' }, + ], + }); + }); +}); diff --git a/packages/slack/src/find-active-slack-task-run.ts b/packages/slack/src/find-active-slack-task-run.ts index cf56a153c..744ddc4a8 100644 --- a/packages/slack/src/find-active-slack-task-run.ts +++ b/packages/slack/src/find-active-slack-task-run.ts @@ -32,6 +32,11 @@ export type SlackTaskRunLookupScope = | { slackTeamId: string; taskId?: string } | { taskId: string; slackTeamId?: string }; +const activeSlackTaskRunSelection = { + ...getTableColumns(taskRuns), + slackThreadTs: tasks.slackThreadTs, +}; + export async function findActiveSlackTaskRun( slackThreadTs: string, scope: SlackTaskRunLookupScope, @@ -41,7 +46,7 @@ export async function findActiveSlackTaskRun( ); const [activeRun] = await db - .select(getTableColumns(taskRuns)) + .select(activeSlackTaskRunSelection) .from(taskRuns) .innerJoin(tasks, eq(tasks.id, taskRuns.taskId)) .where( @@ -106,3 +111,27 @@ export async function findActiveSlackTaskRun( return activeRun ?? null; } + +/** Find the latest active task in a Slack DM, whose messages are not always threaded. */ +export async function findActiveSlackTaskRunByChannel( + slackChannelId: string, + scope: { slackTeamId: string }, +) { + const [activeRun] = await db + .select(activeSlackTaskRunSelection) + .from(taskRuns) + .innerJoin(tasks, eq(tasks.id, taskRuns.taskId)) + .where( + and( + eq(tasks.slackChannelId, slackChannelId), + getSlackTaskRunWorkspacePredicate(scope.slackTeamId), + inArray(taskRuns.status, [...activeRunStatuses]), + isNull(taskRuns.canceledAt), + isNull(tasks.deletedAt), + ), + ) + .orderBy(desc(taskRuns.createdAt)) + .limit(1); + + return activeRun ?? null; +} From 3511e012af562257003cc7982356a27298743f1c Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:53:08 +0000 Subject: [PATCH 2/2] fix: defer Slack task lookup selection --- packages/slack/src/find-active-slack-task-run.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/slack/src/find-active-slack-task-run.ts b/packages/slack/src/find-active-slack-task-run.ts index 744ddc4a8..a98723e1e 100644 --- a/packages/slack/src/find-active-slack-task-run.ts +++ b/packages/slack/src/find-active-slack-task-run.ts @@ -32,10 +32,12 @@ export type SlackTaskRunLookupScope = | { slackTeamId: string; taskId?: string } | { taskId: string; slackTeamId?: string }; -const activeSlackTaskRunSelection = { - ...getTableColumns(taskRuns), - slackThreadTs: tasks.slackThreadTs, -}; +function getActiveSlackTaskRunSelection() { + return { + ...getTableColumns(taskRuns), + slackThreadTs: tasks.slackThreadTs, + }; +} export async function findActiveSlackTaskRun( slackThreadTs: string, @@ -46,7 +48,7 @@ export async function findActiveSlackTaskRun( ); const [activeRun] = await db - .select(activeSlackTaskRunSelection) + .select(getActiveSlackTaskRunSelection()) .from(taskRuns) .innerJoin(tasks, eq(tasks.id, taskRuns.taskId)) .where( @@ -118,7 +120,7 @@ export async function findActiveSlackTaskRunByChannel( scope: { slackTeamId: string }, ) { const [activeRun] = await db - .select(activeSlackTaskRunSelection) + .select(getActiveSlackTaskRunSelection()) .from(taskRuns) .innerJoin(tasks, eq(tasks.id, taskRuns.taskId)) .where(