Skip to content
Merged
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
85 changes: 71 additions & 14 deletions apps/api/src/handlers/tasks/manageSourceControl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { Context } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { z } from 'zod';
import {
clearLatestUserMessageForReplyQuoteIfId,
getLatestUserMessageForReplyQuote,
} from '@roomote/communication/messages';
import { resolveSourceControlProviderFromPayload } from '@roomote/types';

import {
createOrUpdateSourceControlPullRequestForTaskRun,
Expand All @@ -27,6 +32,27 @@ import {
} from '../mcp/proxy-utils';
import { logHandlerError } from '../utils';

const GITHUB_REPLY_QUOTE_MAX_LENGTH = 280;

function formatGitHubReplyQuote(params: {
userName: string;
text: string;
}): string | null {
const userName = params.userName.replace(/\s+/g, ' ').trim();
const text = params.text.trim();

if (!userName || !text) {
return null;
}

const truncated =
text.length <= GITHUB_REPLY_QUOTE_MAX_LENGTH
? text
: `${text.slice(0, GITHUB_REPLY_QUOTE_MAX_LENGTH - 3).trimEnd()}...`;

return `> **${userName}:** ${truncated.replaceAll('\n', '\n> ')}`;
}

/**
* POST /api/mcp/tasks/:taskId/source_control
*
Expand Down Expand Up @@ -66,6 +92,35 @@ export async function manageSourceControl(
runId: auth.authContext.runId,
taskId,
});
const isGitHubTask =
resolveSourceControlProviderFromPayload(taskRun.payload) === 'github';
const bodyInput = 'body' in input ? input : null;
const shouldQuote =
isGitHubTask &&
(input.action === 'reply_to_pull_request_comment' ||
input.action === 'create_pull_request_comment' ||
input.action === 'create_issue_comment') &&
typeof bodyInput?.body === 'string';
const pendingQuote = shouldQuote
? await getLatestUserMessageForReplyQuote('github', taskRun.id)
: null;

if (pendingQuote && typeof bodyInput?.body === 'string') {
const quote = formatGitHubReplyQuote(pendingQuote);
if (quote) {
bodyInput.body = `${quote}\n\n${bodyInput.body}`;
}
}

const clearQuoteAfterSuccess = async () => {
if (pendingQuote) {
await clearLatestUserMessageForReplyQuoteIfId(
'github',
taskRun.id,
pendingQuote.id,
);
}
};

switch (input.action) {
case 'create_or_update_pull_request':
Expand All @@ -88,22 +143,24 @@ export async function manageSourceControl(
case 'create_pull_request_comment':
case 'resolve_pull_request_thread':
case 'submit_pull_request_review':
case 'update_pull_request_comment':
return c.json(
await writeSourceControlPullRequestForTaskRun({
taskRun,
input,
}),
);
case 'update_pull_request_comment': {
const writeResult = await writeSourceControlPullRequestForTaskRun({
taskRun,
input,
});
await clearQuoteAfterSuccess();
return c.json(writeResult);
}
case 'get_issue':
case 'list_issue_comments':
case 'create_issue_comment':
return c.json(
await manageSourceControlIssueForTaskRun({
taskRun,
input,
}),
);
case 'create_issue_comment': {
const issueResult = await manageSourceControlIssueForTaskRun({
taskRun,
input,
});
await clearQuoteAfterSuccess();
return c.json(issueResult);
}
}
} catch (error) {
if (error instanceof z.ZodError) {
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/trpc/commands/sandbox-session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
getEnvironmentDefinitionIdFromPayload,
isBootingRunStatus,
isExitedRunStatus,
resolveSourceControlProviderFromPayload,
taskToolDispatchPayloadSchema,
} from '@roomote/types';
import { createRunToken } from '@roomote/auth';
import { trackLatestUserMessageForReplyQuote } from '@roomote/communication/messages';
import {
and,
compareAndSetTrustedRunActingUser,
Expand Down Expand Up @@ -296,7 +298,7 @@ export async function sendSandboxPromptCommand(
],
});

return await client.commands.sendPrompt.mutate({
const result = await client.commands.sendPrompt.mutate({
prompt:
outOfBandContext && typeof parsed.prompt === 'string'
? withOutOfBandContext(outOfBandContext, parsed.prompt)
Expand All @@ -314,6 +316,21 @@ export async function sendSandboxPromptCommand(
? true
: parsed.autoSteerWhenQueued,
});

if (
parsed.source === 'web' &&
typeof parsed.prompt === 'string' &&
resolveSourceControlProviderFromPayload(taskRun.payload) === 'github'
) {
await trackLatestUserMessageForReplyQuote({
provider: 'github',
runId: taskRun.id,
text: parsed.prompt,
userName: getAuthenticatedPromptUserName(auth) ?? 'Someone',
});
}

return result;
} catch (error) {
await releaseOutOfBandContext(outOfBandContext);

Expand Down
18 changes: 18 additions & 0 deletions packages/communication/src/__tests__/messages.test.ts

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

16 changes: 9 additions & 7 deletions packages/communication/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
queuedCommunicationMessageSchema,
} from '@roomote/types';

type ReplyQuoteProvider = CommunicationProvider | 'github';

const COMMUNICATION_MESSAGE_TTL_SECONDS = 60 * 60;
const COMMUNICATION_MESSAGE_DEDUPE_TTL_SECONDS = 24 * 60 * 60;
const LATEST_INBOUND_MESSAGE_ID_TTL_SECONDS = 60 * 60 * 24;
Expand All @@ -20,7 +22,7 @@ export interface LatestUserMessageForReplyQuote {
}

type TrackLatestUserMessageForReplyQuoteParams = {
provider: CommunicationProvider;
provider: ReplyQuoteProvider;
runId: number;
text: string;
userName: string;
Expand Down Expand Up @@ -72,7 +74,7 @@ function getLatestInboundMessageIdKey(
}

function getLatestUserMessageForReplyQuoteKey(
provider: CommunicationProvider,
provider: ReplyQuoteProvider,
runId: number,
): string {
return `${provider}:latest_user_message:${runId}`;
Expand Down Expand Up @@ -108,7 +110,7 @@ function parseLatestUserMessageForReplyQuote(
}

function getCommunicationMessageDedupeKey(
provider: CommunicationProvider,
provider: ReplyQuoteProvider,
runId: number,
messageId: string,
): string {
Expand Down Expand Up @@ -350,7 +352,7 @@ export async function getLatestInboundMessageId(
* quote it back into the originating chat thread (Discord parity with Slack).
*/
export async function setLatestUserMessageForReplyQuote(
provider: CommunicationProvider,
provider: ReplyQuoteProvider,
runId: number,
message: Omit<LatestUserMessageForReplyQuote, 'id'> & { id?: string },
): Promise<LatestUserMessageForReplyQuote> {
Expand Down Expand Up @@ -399,7 +401,7 @@ export async function trackLatestUserMessageForReplyQuote({
}

export async function getLatestUserMessageForReplyQuote(
provider: CommunicationProvider,
provider: ReplyQuoteProvider,
runId: number,
): Promise<LatestUserMessageForReplyQuote | null> {
const redis = getRedis();
Expand All @@ -410,7 +412,7 @@ export async function getLatestUserMessageForReplyQuote(
}

export async function clearLatestUserMessageForReplyQuote(
provider: CommunicationProvider,
provider: ReplyQuoteProvider,
runId: number,
): Promise<void> {
try {
Expand All @@ -430,7 +432,7 @@ export async function clearLatestUserMessageForReplyQuote(
* was quoted on this delivery. Prevents clearing a newer same-text follow-up.
*/
export async function clearLatestUserMessageForReplyQuoteIfId(
provider: CommunicationProvider,
provider: ReplyQuoteProvider,
runId: number,
id: string,
): Promise<boolean> {
Expand Down
Loading