feat: send user request as separate content block for slash command support
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 <noreply@anthropic.com>
This commit is contained in:
parent
b89827f8d1
commit
e20b01b71c
@ -1,14 +1,73 @@
|
|||||||
import * as core from "@actions/core";
|
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 { query } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type {
|
import type {
|
||||||
SDKMessage,
|
SDKMessage,
|
||||||
SDKResultMessage,
|
SDKResultMessage,
|
||||||
|
SDKUserMessage,
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
||||||
|
|
||||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file exists
|
||||||
|
*/
|
||||||
|
async function fileExists(path: string): Promise<boolean> {
|
||||||
|
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<string | AsyncIterable<SDKUserMessage>> {
|
||||||
|
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<SDKUserMessage> {
|
||||||
|
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
|
* Sanitizes SDK output to match CLI sanitization behavior
|
||||||
*/
|
*/
|
||||||
@ -63,7 +122,8 @@ export async function runClaudeWithSdk(
|
|||||||
promptPath: string,
|
promptPath: string,
|
||||||
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
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) {
|
if (!showFullOutput) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import type { ParsedGitHubContext } from "../github/context";
|
|||||||
import type { CommonFields, PreparedContext, EventData } from "./types";
|
import type { CommonFields, PreparedContext, EventData } from "./types";
|
||||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||||
import type { Mode, ModeContext } from "../modes/types";
|
import type { Mode, ModeContext } from "../modes/types";
|
||||||
|
import { extractUserRequest } from "../utils/extract-user-request";
|
||||||
export type { CommonFields, PreparedContext } from "./types";
|
export type { CommonFields, PreparedContext } from "./types";
|
||||||
|
|
||||||
// Tag mode defaults - these tools are needed for tag mode to function
|
// 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;
|
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(
|
export async function createPrompt(
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
modeContext: ModeContext,
|
modeContext: ModeContext,
|
||||||
@ -889,6 +928,22 @@ export async function createPrompt(
|
|||||||
promptContent,
|
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
|
// Set allowed tools
|
||||||
const hasActionsReadPermission = false;
|
const hasActionsReadPermission = false;
|
||||||
|
|
||||||
|
|||||||
89
src/utils/extract-user-request.ts
Normal file
89
src/utils/extract-user-request.ts
Normal file
@ -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.";
|
||||||
|
}
|
||||||
162
test/extract-user-request.test.ts
Normal file
162
test/extract-user-request.test.ts
Normal file
@ -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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user