diff --git a/apps/api/src/handlers/tasks/manageSourceControl.ts b/apps/api/src/handlers/tasks/manageSourceControl.ts index c7ce752f5..099a79116 100644 --- a/apps/api/src/handlers/tasks/manageSourceControl.ts +++ b/apps/api/src/handlers/tasks/manageSourceControl.ts @@ -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, @@ -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 * @@ -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': @@ -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) { diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index fd3f505bc..82928fc61 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -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, @@ -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) @@ -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); diff --git a/packages/communication/src/__tests__/messages.test.ts b/packages/communication/src/__tests__/messages.test.ts index d7b0d930a..7479dbcbe 100644 --- a/packages/communication/src/__tests__/messages.test.ts +++ b/packages/communication/src/__tests__/messages.test.ts @@ -314,6 +314,24 @@ describe('communication message queues', () => { ); }); + it('supports GitHub reply quotes without adding GitHub as a chat provider', async () => { + await setLatestUserMessageForReplyQuote('github', 44, { + text: 'ship it', + userName: 'Ada', + }); + + expect(setMock).toHaveBeenCalledWith( + 'github:latest_user_message:44', + JSON.stringify({ + id: 'quote-id-fixed', + text: 'ship it', + userName: 'Ada', + }), + 'EX', + 30 * 24 * 60 * 60, + ); + }); + it('reads the latest user message', async () => { getMock.mockResolvedValueOnce( JSON.stringify({ diff --git a/packages/communication/src/messages.ts b/packages/communication/src/messages.ts index f7e746bfe..31ee41131 100644 --- a/packages/communication/src/messages.ts +++ b/packages/communication/src/messages.ts @@ -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; @@ -20,7 +22,7 @@ export interface LatestUserMessageForReplyQuote { } type TrackLatestUserMessageForReplyQuoteParams = { - provider: CommunicationProvider; + provider: ReplyQuoteProvider; runId: number; text: string; userName: string; @@ -72,7 +74,7 @@ function getLatestInboundMessageIdKey( } function getLatestUserMessageForReplyQuoteKey( - provider: CommunicationProvider, + provider: ReplyQuoteProvider, runId: number, ): string { return `${provider}:latest_user_message:${runId}`; @@ -108,7 +110,7 @@ function parseLatestUserMessageForReplyQuote( } function getCommunicationMessageDedupeKey( - provider: CommunicationProvider, + provider: ReplyQuoteProvider, runId: number, messageId: string, ): string { @@ -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 & { id?: string }, ): Promise { @@ -399,7 +401,7 @@ export async function trackLatestUserMessageForReplyQuote({ } export async function getLatestUserMessageForReplyQuote( - provider: CommunicationProvider, + provider: ReplyQuoteProvider, runId: number, ): Promise { const redis = getRedis(); @@ -410,7 +412,7 @@ export async function getLatestUserMessageForReplyQuote( } export async function clearLatestUserMessageForReplyQuote( - provider: CommunicationProvider, + provider: ReplyQuoteProvider, runId: number, ): Promise { try { @@ -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 {