Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/goal-mode-surfaces.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 2 additions & 61 deletions apps/api/src/handlers/discord/goal-command.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,10 @@
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;
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 };
return startTaskGoal({ ...input, source: 'discord' });
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 56 additions & 9 deletions apps/api/src/handlers/slack/events/message-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -1541,6 +1562,7 @@ async function handleSlackEntryEvent(params: {
completionEmoji: string;
skipThreadFollowupHandling?: boolean;
prefetchedThreadMessages?: SlackThreadMessage[];
goalCommand?: SlackGoalCommand | null;
}): Promise<void> {
const {
event,
Expand All @@ -1551,6 +1573,7 @@ async function handleSlackEntryEvent(params: {
completionEmoji,
skipThreadFollowupHandling = false,
prefetchedThreadMessages,
goalCommand = null,
} = params;

if (!event.user) {
Expand Down Expand Up @@ -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 &&
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1927,5 +1973,6 @@ export async function handleMessageOrAppMentionEvent(params: {
prefetchedThreadMessages: unmentionedThreadReplyRouting.shouldRoute
? unmentionedThreadReplyRouting.threadMessages
: undefined,
goalCommand,
});
}
Loading
Loading