From e20b01b71c551aa7c5446b3913ece3d31f8b771a Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Tue, 23 Dec 2025 10:57:24 -0800 Subject: [PATCH] feat: send user request as separate content block for slash command support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When in tag mode with the SDK path, extracts the user's request from the trigger comment (text after @claude) and sends it as a separate content block. This enables the CLI to process slash commands like "/review-pr". - Add extract-user-request utility to parse trigger comments - Write user request to separate file during prompt generation - Send multi-block SDKUserMessage when user request file exists - Add tests for the extraction utility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- base-action/src/run-claude-sdk.ts | 64 +++++++++++- src/create-prompt/index.ts | 55 ++++++++++ src/utils/extract-user-request.ts | 89 ++++++++++++++++ test/extract-user-request.test.ts | 162 ++++++++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 src/utils/extract-user-request.ts create mode 100644 test/extract-user-request.test.ts diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index 2bf0b24..61154b7 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -1,14 +1,73 @@ import * as core from "@actions/core"; -import { readFile, writeFile } from "fs/promises"; +import { readFile, writeFile, access } from "fs/promises"; +import { dirname, join } from "path"; import { query } from "@anthropic-ai/claude-agent-sdk"; import type { SDKMessage, SDKResultMessage, + SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import type { ParsedSdkOptions } from "./parse-sdk-options"; const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; +/** + * Check if a file exists + */ +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** + * Creates a prompt configuration for the SDK. + * If a user request file exists alongside the prompt file, returns a multi-block + * SDKUserMessage that enables slash command processing in the CLI. + * Otherwise, returns the prompt as a simple string. + */ +async function createPromptConfig( + promptPath: string, +): Promise> { + const promptContent = await readFile(promptPath, "utf-8"); + + // Check for user request file in the same directory + const userRequestPath = join(dirname(promptPath), "claude-user-request.txt"); + const hasUserRequest = await fileExists(userRequestPath); + + if (!hasUserRequest) { + // No user request file - use simple string prompt + return promptContent; + } + + // User request file exists - create multi-block message + const userRequest = await readFile(userRequestPath, "utf-8"); + console.log("Using multi-block message with user request:", userRequest); + + // Create an async generator that yields a single multi-block message + // The context/instructions go first, then the user's actual request last + // This allows the CLI to detect and process slash commands in the user request + async function* createMultiBlockMessage(): AsyncGenerator { + yield { + type: "user", + session_id: "", + message: { + role: "user", + content: [ + { type: "text", text: promptContent }, // Instructions + GitHub context + { type: "text", text: userRequest }, // User's request (may be a slash command) + ], + }, + parent_tool_use_id: null, + }; + } + + return createMultiBlockMessage(); +} + /** * Sanitizes SDK output to match CLI sanitization behavior */ @@ -63,7 +122,8 @@ export async function runClaudeWithSdk( promptPath: string, { sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions, ): Promise { - const prompt = await readFile(promptPath, "utf-8"); + // Create prompt configuration - may be a string or multi-block message + const prompt = await createPromptConfig(promptPath); if (!showFullOutput) { console.log( diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 7a62e6e..44d8aeb 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -21,6 +21,7 @@ import type { ParsedGitHubContext } from "../github/context"; import type { CommonFields, PreparedContext, EventData } from "./types"; import { GITHUB_SERVER_URL } from "../github/api/config"; import type { Mode, ModeContext } from "../modes/types"; +import { extractUserRequest } from "../utils/extract-user-request"; export type { CommonFields, PreparedContext } from "./types"; // Tag mode defaults - these tools are needed for tag mode to function @@ -841,6 +842,44 @@ f. If you are unable to complete certain steps, such as running a linter or test return promptContent; } +/** + * Extracts the user's request from the prepared context and GitHub data. + * This is used to send the user's actual command/request as a separate + * content block, enabling slash command processing in the CLI. + */ +function extractUserRequestFromContext( + context: PreparedContext, + githubData: FetchDataResult, +): string | null { + const { eventData, triggerPhrase } = context; + + // For comment events, extract from comment body + if ( + "commentBody" in eventData && + eventData.commentBody && + (eventData.eventName === "issue_comment" || + eventData.eventName === "pull_request_review_comment" || + eventData.eventName === "pull_request_review") + ) { + return extractUserRequest(eventData.commentBody, triggerPhrase); + } + + // For issue/PR events triggered by body content, extract from the body + if (githubData.contextData?.body) { + const request = extractUserRequest( + githubData.contextData.body, + triggerPhrase, + ); + if (request) { + return request; + } + } + + // For assigned/labeled events without explicit trigger in body, + // return null to indicate the full context should be used + return null; +} + export async function createPrompt( mode: Mode, modeContext: ModeContext, @@ -889,6 +928,22 @@ export async function createPrompt( promptContent, ); + // Extract and write the user request separately for SDK multi-block messaging + // This allows the CLI to process slash commands (e.g., "@claude /review-pr") + const userRequest = extractUserRequestFromContext( + preparedContext, + githubData, + ); + if (userRequest) { + await writeFile( + `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-user-request.txt`, + userRequest, + ); + console.log("===== USER REQUEST ====="); + console.log(userRequest); + console.log("========================"); + } + // Set allowed tools const hasActionsReadPermission = false; diff --git a/src/utils/extract-user-request.ts b/src/utils/extract-user-request.ts new file mode 100644 index 0000000..52793d4 --- /dev/null +++ b/src/utils/extract-user-request.ts @@ -0,0 +1,89 @@ +/** + * Extracts the user's request from a trigger comment. + * + * Given a comment like "@claude /review-pr please check the auth module", + * this extracts "/review-pr please check the auth module". + * + * @param commentBody - The full comment body containing the trigger phrase + * @param triggerPhrase - The trigger phrase (e.g., "@claude") + * @returns The user's request (text after the trigger phrase), or null if not found + */ +export function extractUserRequest( + commentBody: string | undefined, + triggerPhrase: string, +): string | null { + if (!commentBody) { + return null; + } + + // Escape special regex characters in the trigger phrase + const escapedTrigger = triggerPhrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // Match the trigger phrase followed by optional whitespace and capture the rest + // The trigger phrase can appear anywhere in the comment + const regex = new RegExp(`${escapedTrigger}\\s*(.*)`, "is"); + const match = commentBody.match(regex); + + if (match && match[1]) { + // Trim and return the captured text after the trigger phrase + const request = match[1].trim(); + return request || null; + } + + return null; +} + +/** + * Extracts the user's request from various GitHub event types. + * + * For comment events: extracts from the comment body + * For issue/PR events: extracts from the body or title + * + * @param eventData - The parsed event data containing comment/body info + * @param triggerPhrase - The trigger phrase (e.g., "@claude") + * @returns The user's request, or a default message if not found + */ +export function extractUserRequestFromEvent( + eventData: { + eventName: string; + commentBody?: string; + issueBody?: string; + prBody?: string; + }, + triggerPhrase: string, +): string { + // For comment events, extract from comment body + if ( + eventData.eventName === "issue_comment" || + eventData.eventName === "pull_request_review_comment" || + eventData.eventName === "pull_request_review" + ) { + const request = extractUserRequest(eventData.commentBody, triggerPhrase); + if (request) { + return request; + } + } + + // For issue events, try extracting from issue body + if (eventData.eventName === "issues" && eventData.issueBody) { + const request = extractUserRequest(eventData.issueBody, triggerPhrase); + if (request) { + return request; + } + } + + // For PR events, try extracting from PR body + if ( + (eventData.eventName === "pull_request" || + eventData.eventName === "pull_request_target") && + eventData.prBody + ) { + const request = extractUserRequest(eventData.prBody, triggerPhrase); + if (request) { + return request; + } + } + + // Default: return a generic request to analyze the context + return "Please analyze the context and help with this request."; +} diff --git a/test/extract-user-request.test.ts b/test/extract-user-request.test.ts new file mode 100644 index 0000000..33dce12 --- /dev/null +++ b/test/extract-user-request.test.ts @@ -0,0 +1,162 @@ +import { describe, test, expect } from "bun:test"; +import { + extractUserRequest, + extractUserRequestFromEvent, +} from "../src/utils/extract-user-request"; + +describe("extractUserRequest", () => { + test("extracts text after @claude trigger", () => { + expect(extractUserRequest("@claude /review-pr", "@claude")).toBe( + "/review-pr", + ); + }); + + test("extracts slash command with arguments", () => { + expect( + extractUserRequest( + "@claude /review-pr please check the auth module", + "@claude", + ), + ).toBe("/review-pr please check the auth module"); + }); + + test("handles trigger phrase with extra whitespace", () => { + expect(extractUserRequest("@claude /review-pr", "@claude")).toBe( + "/review-pr", + ); + }); + + test("handles trigger phrase at start of multiline comment", () => { + const comment = `@claude /review-pr +Please review this PR carefully. +Focus on security issues.`; + expect(extractUserRequest(comment, "@claude")).toBe( + `/review-pr +Please review this PR carefully. +Focus on security issues.`, + ); + }); + + test("handles trigger phrase in middle of text", () => { + expect( + extractUserRequest("Hey team, @claude can you review this?", "@claude"), + ).toBe("can you review this?"); + }); + + test("returns null for empty comment body", () => { + expect(extractUserRequest("", "@claude")).toBeNull(); + }); + + test("returns null for undefined comment body", () => { + expect(extractUserRequest(undefined, "@claude")).toBeNull(); + }); + + test("returns null when trigger phrase not found", () => { + expect(extractUserRequest("Please review this PR", "@claude")).toBeNull(); + }); + + test("returns null when only trigger phrase with no request", () => { + expect(extractUserRequest("@claude", "@claude")).toBeNull(); + }); + + test("handles custom trigger phrase", () => { + expect(extractUserRequest("/claude help me", "/claude")).toBe("help me"); + }); + + test("handles trigger phrase with special regex characters", () => { + expect( + extractUserRequest("@claude[bot] do something", "@claude[bot]"), + ).toBe("do something"); + }); + + test("is case insensitive", () => { + expect(extractUserRequest("@CLAUDE /review-pr", "@claude")).toBe( + "/review-pr", + ); + expect(extractUserRequest("@Claude /review-pr", "@claude")).toBe( + "/review-pr", + ); + }); +}); + +describe("extractUserRequestFromEvent", () => { + test("extracts from issue_comment event", () => { + const result = extractUserRequestFromEvent( + { + eventName: "issue_comment", + commentBody: "@claude /review-pr", + }, + "@claude", + ); + expect(result).toBe("/review-pr"); + }); + + test("extracts from pull_request_review_comment event", () => { + const result = extractUserRequestFromEvent( + { + eventName: "pull_request_review_comment", + commentBody: "@claude fix this bug", + }, + "@claude", + ); + expect(result).toBe("fix this bug"); + }); + + test("extracts from pull_request_review event", () => { + const result = extractUserRequestFromEvent( + { + eventName: "pull_request_review", + commentBody: "@claude looks good but add tests", + }, + "@claude", + ); + expect(result).toBe("looks good but add tests"); + }); + + test("extracts from issues event with body", () => { + const result = extractUserRequestFromEvent( + { + eventName: "issues", + issueBody: "@claude please implement this feature", + }, + "@claude", + ); + expect(result).toBe("please implement this feature"); + }); + + test("extracts from pull_request event with body", () => { + const result = extractUserRequestFromEvent( + { + eventName: "pull_request", + prBody: "@claude review this PR", + }, + "@claude", + ); + expect(result).toBe("review this PR"); + }); + + test("returns default message when no trigger found", () => { + const result = extractUserRequestFromEvent( + { + eventName: "issues", + issueBody: "Please fix the login bug", + }, + "@claude", + ); + expect(result).toBe( + "Please analyze the context and help with this request.", + ); + }); + + test("returns default message for assigned events without trigger", () => { + const result = extractUserRequestFromEvent( + { + eventName: "issues", + }, + "@claude", + ); + expect(result).toBe( + "Please analyze the context and help with this request.", + ); + }); +});