Ctrl K
ComunidadGuías…

mcp/comment.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { type } from "arktype";
import { formatMcpToolRef, isIssueTarget } from "../external.ts";
import { primaryRepoState } from "../toolState.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { isPullfrog } from "../utils/isPullfrog.ts";
import { countOutstandingPullfrogThreads } from "../utils/outstandingThreads.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
import {
  createLeapingProgressComment,
  deleteProgressCommentApi,
  updateProgressComment,
} from "../utils/progressComment.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";

// re-export for backward compat with anything importing the leaping helpers from mcp/comment
export {
  isLeapingIntoActionCommentBody,
  LEAPING_INTO_ACTION_PREFIX,
} from "../utils/leapingComment.ts";

function isNotFoundError(error: unknown): boolean {
  return error instanceof Error && error.message.includes("Not Found");
}

function buildCommentFooter(ctx: ToolContext, customParts?: string[]): string {
  const runId = ctx.runId;
  return buildPullfrogFooter({
    triggeredBy: true,
    workflowRun:
      runId !== undefined
        ? {
            owner: ctx.repo.owner,
            repo: ctx.repo.name,
            runId,
            jobId: ctx.jobId,
          }
        : undefined,
    customParts,
    model: ctx.toolState.model,
    clamped: ctx.toolState.modelClamped,
    unselectedProxyDefault: ctx.toolState.unselectedProxyDefault,
    oss: ctx.oss,
    owner: ctx.repo.owner,
  });
}

function buildImplementPlanLink(ctx: ToolContext, issueNumber: number, commentId: number): string {
  const apiUrl = getApiUrl();
  return `[Implement plan ➔](${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${issueNumber}?action=implement&comment_id=${commentId})`;
}

/** the issue a run answers on: its trigger's, else the first issue a run with no target read */
function runIssueNumber(ctx: ToolContext): number | undefined {
  return ctx.payload.event.issue_number ?? primaryRepoState(ctx.toolState).issueNumber;
}

function buildEnrichmentLinks(ctx: ToolContext, issueNumber: number | undefined): string[] {
  const event = ctx.payload.event;
  const mode = ctx.toolState.selectedMode;
  const enriching =
    mode === "Enrich" ||
    (event.trigger === "issues_opened" && !ctx.payload.prompt && mode !== "Build");
  if (issueNumber === undefined || event.is_pr || !enriching) return [];
  return [
    `[Implement a fix ➔](${getApiUrl()}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${issueNumber}?action=build)`,
  ];
}

export function addFooter(ctx: ToolContext, body: string): string {
  if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) {
    throw new Error(
      "body contains <br/> followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after <br/> tags."
    );
  }
  const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
  const footer = buildCommentFooter(ctx);
  return `${bodyWithoutFooter}${footer}`;
}

export const Comment = type({
  issueNumber: type.number.describe("the issue number to comment on"),
  body: type.string.describe("the comment body content"),
  type: type
    .enumerated("Plan", "Comment")
    .describe(
      "Plan: standalone plan comment on another target. Comment: regular comment (default)."
    )
    .optional(),
});

export function CreateCommentTool(ctx: ToolContext) {
  return tool({
    name: "create_issue_comment",
    mutates: true,
    description:
      "Create a comment on a GitHub issue or PR. " +
      'Example: `create_issue_comment({ issueNumber: 1234, body: "Thanks for the report." })`. ' +
      "For the current run's answer, progress, or plan use report_progress instead. Use this on the current target only when the task explicitly requests a standalone comment. Skip report_progress only when that current-target comment is the task's sole requested deliverable.",
    parameters: Comment,
    execute: execute(async ({ issueNumber, body, type: commentType }) => {
      const bodyWithFooter = addFooter(ctx, body);

      const result = await ctx.octokit.rest.issues.createComment({
        owner: ctx.repo.owner,
        repo: ctx.repo.name,
        issue_number: issueNumber,
        body: bodyWithFooter,
      });

      ctx.toolState.wasUpdated = true;
      log.info(`» created comment ${result.data.id}`);

      // a standalone comment on the run's OWN target is the deliverable, so
      // report_progress must not add a second one (see its guard).
      const ownTarget = issueNumber === runIssueNumber(ctx);
      if (ownTarget) {
        ctx.toolState.standaloneCommentId = result.data.id;
      }

      const enrichmentLinks = ownTarget ? buildEnrichmentLinks(ctx, issueNumber) : [];
      if (commentType === "Plan" || enrichmentLinks.length > 0) {
        if (commentType === "Plan" && result.data.node_id) {
          await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
        }
        // add "Implement plan" link (needs comment ID, so create-then-update)
        const customParts =
          commentType === "Plan"
            ? [buildImplementPlanLink(ctx, issueNumber, result.data.id)]
            : enrichmentLinks;
        const footer = buildCommentFooter(ctx, customParts);
        const bodyWithPlanLink = `${stripExistingFooter(bodyWithFooter)}${footer}`;

        const updateResult = await ctx.octokit.rest.issues.updateComment({
          owner: ctx.repo.owner,
          repo: ctx.repo.name,
          comment_id: result.data.id,
          body: bodyWithPlanLink,
        });
        log.info(`» updated comment ${updateResult.data.id}`);

        return {
          success: true,
          commentId: updateResult.data.id,
          url: updateResult.data.html_url,
          body: updateResult.data.body,
        };
      }

      return {
        success: true,
        commentId: result.data.id,
        url: result.data.html_url,
        body: result.data.body,
      };
    }),
  });
}

export const EditComment = type({
  commentId: type.number.describe("the ID of the comment to edit"),
  body: type.string.describe("the new comment body content"),
});

export function EditCommentTool(ctx: ToolContext) {
  return tool({
    name: "edit_issue_comment",
    mutates: true,
    description: "Edit a GitHub issue comment by its ID",
    parameters: EditComment,
    execute: execute(async ({ commentId, body }) => {
      const bodyWithFooter = addFooter(ctx, body);

      const result = await ctx.octokit.rest.issues.updateComment({
        owner: ctx.repo.owner,
        repo: ctx.repo.name,
        comment_id: commentId,
        body: bodyWithFooter,
      });
      log.info(`» updated comment ${result.data.id}`);

      return {
        success: true,
        commentId: result.data.id,
        url: result.data.html_url,
        body: result.data.body,
        updatedAt: result.data.updated_at,
      };
    }),
  });
}

export const ReportProgress = type({
  body: type.string.describe("the progress update content to share"),
  "target_plan_comment?": type("boolean").describe(
    "for revising an existing plan comment ONLY. set to true only when the PlanEdit checklist from select_mode tells you to (i.e. a prior plan comment was found for this issue). NEVER set on the initial plan post — the initial plan reuses the run's progress comment and is posted by calling report_progress without this flag."
  ),
  "no_review_needed?": type("boolean").describe(
    "IncrementalReview only: true when you judged the new commits need no review, never for a blocker."
  ),
});

/**
 * Report progress to a GitHub comment.
 *
 * progressComment has three states:
 *   - unseeded: no comment yet — will create one if an issue/PR target exists
 *   - open:     active comment — will update it in place via the right REST endpoint for its type
 *   - closed:   deliberately deleted (e.g. after submitting a PR review) — skips silently
 *
 * The body is tracked in lastProgressBody for the job summary regardless of comment state,
 * EXCEPT for `liveProgress` (todo-tracker) writes — see the param note below.
 *
 * The "existing plan comment" path always targets a top-level issue comment (plan comments are
 * created by create_issue_comment with type:"Plan", never as review-thread replies).
 */
export async function reportProgress(
  ctx: ToolContext,
  params: { body: string; target_plan_comment?: boolean; liveProgress?: boolean }
): Promise<{
  commentId?: number;
  url?: string;
  body: string;
  action: "created" | "updated" | "skipped";
}> {
  const { body, target_plan_comment } = params;
  // `liveProgress` marks the automatic todo-tracker checklist render — a live
  // progress update, NOT the agent's deliberate final answer. such writes must
  // not record `lastProgressBody` or flip `wasUpdated`: both signal "a real
  // user-facing answer landed", and letting an auto checklist trip them masks
  // the #868 salvage (it would post the checklist instead of the real output,
  // or skip salvage entirely) and triggers stranded-comment deletion.
  if (!params.liveProgress) {
    ctx.toolState.lastProgressBody = body;
  }

  // silent events (e.g., auto-label, pr-summary Task) should never create or update progress comments.
  // the body is still tracked above for the GitHub Actions job summary.
  if (ctx.payload.event.silent) {
    return { body, action: "skipped" };
  }

  // progress comments opted out: drop the auto-rendered task-list writes, which are
  // the whole cost (one `issue_comment.edited` webhook each for anything watching
  // the PR). a deliberate report_progress still lands — it carries the run's actual
  // answer, and posting it once at the end is not "progress chrome".
  if (params.liveProgress && !ctx.payload.progressComments) {
    return { body, action: "skipped" };
  }

  const issueNumber = runIssueNumber(ctx);
  const isPlanMode = ctx.toolState.selectedMode === "Plan";
  const enrichmentLinks = params.liveProgress ? [] : buildEnrichmentLinks(ctx, issueNumber);
  const apiCtx = { octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name };

  // when editing existing plan: update the plan comment from tool state (set by select_mode)
  if (target_plan_comment === true && ctx.toolState.existingPlanCommentId === undefined) {
    log.warning("target_plan_comment requested but no existingPlanCommentId in tool state");
  }
  if (target_plan_comment === true && ctx.toolState.existingPlanCommentId !== undefined) {
    const commentId = ctx.toolState.existingPlanCommentId;
    const customParts =
      issueNumber !== undefined ? [buildImplementPlanLink(ctx, issueNumber, commentId)] : undefined;
    const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
    const footer = buildCommentFooter(ctx, customParts);
    const bodyWithFooter = `${bodyWithoutFooter}${footer}`;

    const result = await updateProgressComment(
      apiCtx,
      { id: commentId, type: "issue" },
      bodyWithFooter
    );

    if (!params.liveProgress) ctx.toolState.wasUpdated = true;

    if (isPlanMode && result.node_id) {
      await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id });
    }

    return {
      commentId: result.id,
      url: result.html_url,
      body: result.body || "",
      action: "updated",
    };
  }

  const progress = ctx.toolState.progressComment;

  switch (progress.status) {
    // if we already have a progress comment, update it
    case "open": {
      const customParts =
        isPlanMode && issueNumber !== undefined
          ? [buildImplementPlanLink(ctx, issueNumber, progress.comment.id)]
          : enrichmentLinks;

      const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
      const footer = buildCommentFooter(ctx, customParts);
      const bodyWithFooter = `${bodyWithoutFooter}${footer}`;

      // a review-reply progress comment (seeded by the AddressReviews dispatch
      // path) can become stale before final delivery — the thread is deleted or
      // otherwise unreachable, so updateReviewComment 404s. rather than fail an
      // already-completed run, fall back to a fresh top-level comment on the PR
      // and retarget future writes there. (#919)
      let result: Awaited<ReturnType<typeof updateProgressComment>>;
      try {
        result = await updateProgressComment(apiCtx, progress.comment, bodyWithFooter);
      } catch (error) {
        // only a deliberate write to a stale review-reply comment falls back. a
        // liveProgress (todo-tracker) 404 rethrows — it must never create a
        // user-facing comment, and the next deliberate report_progress recovers.
        if (
          params.liveProgress ||
          progress.comment.type !== "review" ||
          !isNotFoundError(error) ||
          issueNumber === undefined
        ) {
          throw error;
        }
        log.warning(
          `progress review comment ${progress.comment.id} is gone (404); posting a top-level comment on #${issueNumber} instead`
        );
        const created = await createLeapingProgressComment(
          apiCtx,
          { kind: "issue", issueNumber },
          bodyWithFooter
        );
        ctx.toolState.progressComment = { status: "open", comment: created.comment };
        if (!params.liveProgress) ctx.toolState.wasUpdated = true;
        return {
          commentId: created.comment.id,
          url: created.html_url,
          body: created.body || "",
          action: "created",
        };
      }

      if (!params.liveProgress) ctx.toolState.wasUpdated = true;

      if (isPlanMode && result.node_id) {
        await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id });
      }

      return {
        commentId: result.id,
        url: result.html_url,
        body: result.body || "",
        action: "updated",
      };
    }
    // closed = shut on purpose (review posted, stranded-comment cleanup)
    case "closed":
      return { body, action: "skipped" };
    case "unseeded":
      break;
    default:
      progress satisfies never;
  }

  // no existing comment - need an issue/PR to create one on
  // use fallback chain: dynamically set context > event payload
  if (issueNumber === undefined) {
    // no-op: no comment target (e.g., workflow_dispatch events)
    // body is already tracked for job summary
    return { body, action: "skipped" };
  }

  // for new comments, we need to create first, then update with Plan link if in Plan mode
  // self-created progress comments are always top-level issue comments — review-reply
  // progress comments only originate from the dispatch path and arrive pre-created.
  const initialBody = addFooter(ctx, body);
  const created = await createLeapingProgressComment(
    apiCtx,
    { kind: "issue", issueNumber },
    initialBody
  );

  ctx.toolState.progressComment = { status: "open", comment: created.comment };
  if (!params.liveProgress) ctx.toolState.wasUpdated = true;

  // if Plan mode, update the comment to add the "Implement plan" link
  if (isPlanMode || enrichmentLinks.length > 0) {
    const customParts = isPlanMode
      ? [buildImplementPlanLink(ctx, issueNumber, created.comment.id)]
      : enrichmentLinks;
    const bodyWithoutFooter = stripExistingFooter(initialBody);
    const footer = buildCommentFooter(ctx, customParts);
    const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;

    const updateResult = await updateProgressComment(apiCtx, created.comment, bodyWithPlanLink);

    if (isPlanMode && updateResult.node_id) {
      await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.node_id });
    }

    return {
      commentId: updateResult.id,
      url: updateResult.html_url,
      body: updateResult.body || "",
      action: "created",
    };
  }

  return {
    commentId: created.comment.id,
    url: created.html_url,
    body: created.body || "",
    action: "created",
  };
}

/**
 * whether skipping the review would strand the PR unapproved: Pullfrog holds no approval (a push
 * dismissed it under "dismiss stale approvals", or none followed a non-approving review) and no
 * Pullfrog finding is open. a no-review exit only carries the prior verdict forward, so here it
 * leaves the PR blocked until someone asks for an approval by hand.
 */
async function isAwaitingApproval(ctx: ToolContext): Promise<boolean> {
  const pullNumber = ctx.payload.event.issue_number;
  if (!ctx.prApproveEnabled || typeof pullNumber !== "number") return false;
  const target = { owner: ctx.repo.owner, repo: ctx.repo.name, pull_number: pullNumber };
  const pr = await ctx.octokit.rest.pulls.get(target);
  // GitHub blocks approving your own PR, so a Pullfrog-authored one can never hold an approval.
  if (pr.data.state !== "open" || isPullfrog(pr.data.user?.login)) return false;
  const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
    ...target,
    per_page: 100,
  });
  // a reviewer's standing verdict is their latest review that is not a comment.
  const standing = reviews.findLast(
    (review) =>
      isPullfrog(review.user?.login) && review.state !== "COMMENTED" && review.state !== "PENDING"
  );
  if (standing?.state === "APPROVED") return false;
  return (await countOutstandingPullfrogThreads(ctx, pullNumber)) === 0;
}

export function ReportProgressTool(ctx: ToolContext) {
  return tool({
    name: "report_progress",
    mutates: true,
    description:
      "Share progress on the associated GitHub issue/PR. The first call creates a comment; subsequent calls update it in place. " +
      'Example: `report_progress({ body: "Implemented the auth check and added tests." })`. ' +
      "Call this at the end of every run with a brief final summary (1-3 sentences) unless the mode guidance instructs otherwise. The current task list is automatically appended in a collapsible section — do not restate individual steps.",
    parameters: ReportProgress,
    execute: execute(async (params) => {
      if (params.no_review_needed) {
        if (await isAwaitingApproval(ctx)) {
          throw new Error(
            "no_review_needed refused: Pullfrog holds no approval on this PR (a push dismissed it, or no review has approved it) and none of its findings is open, so skipping the review leaves the PR unapproved. " +
              `submit \`${formatMcpToolRef(ctx.agentId, "create_pull_request_review")}\` instead: approve unless an earlier Pullfrog concern, inline or in a review body, still stands, and otherwise name it in a non-approving review.`
          );
        }
        ctx.toolState.noReviewNeededSha = primaryRepoState(ctx.toolState).checkoutSha;
      }
      // a standalone comment already delivered this run's answer to its own
      // target. writing here too leaves two comments restating each other, and
      // flipping finalSummaryWritten would also preserve the progress comment
      // that run-end cleanup would otherwise remove. decline instead.
      if (ctx.toolState.standaloneCommentId !== undefined && !params.target_plan_comment) {
        // keep the composed body: runLifecycle falls back to raw agent output for
        // the Actions job summary when lastProgressBody is unset.
        ctx.toolState.lastProgressBody = params.body;
        return {
          success: true,
          action: "skipped",
          message: `standalone comment ${ctx.toolState.standaloneCommentId} already delivered this run's answer to this target — that comment IS the deliverable, so this call was a no-op rather than posting a second one. Nothing further is needed.`,
        };
      }

      let body = params.body;

      // for non-plan calls: stop auto-updates, wait for in-flight writes to settle,
      // then append completed task list collapsible
      if (!params.target_plan_comment && ctx.toolState.todoTracker) {
        ctx.toolState.todoTracker.cancel();
        await ctx.toolState.todoTracker.settled();
        const collapsible = ctx.toolState.todoTracker.renderCollapsible({
          completeInProgress: true,
        });
        if (collapsible) {
          body = `${body}\n\n${collapsible}`;
        }
      }

      const reportParams: { body: string; target_plan_comment?: boolean } = { body };
      if (params.target_plan_comment !== undefined) {
        reportParams.target_plan_comment = params.target_plan_comment;
      }
      const result = await reportProgress(ctx, reportParams);

      if (result.action === "skipped") {
        return {
          success: true,
          message:
            "progress recorded (no GitHub comment created - this may occur for workflow_dispatch events or when there is no associated issue/PR)",
        };
      }

      if (result.commentId !== undefined) {
        log.info(`» ${result.action} comment ${result.commentId}`);
      }

      if (!params.target_plan_comment) {
        ctx.toolState.finalSummaryWritten = true;
      }

      return {
        success: true,
        ...result,
      };
    }),
  });
}

/**
 * A submitted review obsoletes the progress comment, unless it landed on a PR that an
 * issue merely named: the issue then keeps a link to it, or its requester sees nothing.
 */
export async function retireProgressCommentForReview(
  ctx: ToolContext,
  reviewUrl: string
): Promise<void> {
  if (!isIssueTarget(ctx.payload.event)) {
    await deleteProgressComment(ctx);
    return;
  }
  ctx.toolState.todoTracker?.cancel();
  await ctx.toolState.todoTracker?.settled();
  await reportProgress(ctx, { body: `Review submitted: ${reviewUrl}` });
  ctx.toolState.finalSummaryWritten = true;
}

/**
 * Delete the progress comment if it exists.
 * Used by runLifecycle.ts for stranded-comment cleanup (orphaned "Leaping into action" or
 * checklist left by the todo tracker when the agent didn't call report_progress).
 * Sets progressComment to closed so subsequent report_progress calls are no-ops.
 */
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
  const existing = ctx.toolState.progressComment;
  if (existing.status !== "open") {
    // nothing to delete, but still close the surface. callers reach here once the
    // run's durable artifact has landed (review submitted, fix approved), so a later
    // report_progress must not open a fresh comment — which it otherwise would when
    // the dispatcher never seeded one under `progressComments: disabled`.
    ctx.toolState.progressComment = { status: "closed" };
    return false;
  }

  try {
    await deleteProgressCommentApi(
      { octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name },
      existing.comment
    );
  } catch (error) {
    // ignore 404 - comment already deleted
    if (!isNotFoundError(error)) throw error;
  }

  // set to closed (not unseeded) so report_progress skips instead of creating a new comment
  ctx.toolState.progressComment = { status: "closed" };

  return true;
}

export const ReplyToReviewComment = type({
  pull_number: type.number.describe("the pull request number"),
  comment_id: type.number.describe("the ID of the review comment to reply to"),
  body: type.string.describe(
    "extremely brief reply (1 sentence max) explaining what was fixed, e.g. 'Fixed by renaming to X' or 'Added null check'"
  ),
});

/**
 * decision returned by `duplicateReplyDecision` when a session has already
 * posted an identical reply to the same parent review comment.
 */
export interface DuplicateReplyDecision {
  kind: "already-replied";
  commentId: number;
  url: string | undefined;
  reason: string;
}

/**
 * decide whether a second reply_to_review_comment call in the same session
 * is a duplicate of an earlier reply to the same parent comment.
 *
 * the agent is instructed to call reply_to_review_comment exactly once per
 * parent comment per AddressReviews session, but in practice it sometimes
 * emits the same call twice. PR #610 reproduced this with Kimi K2:
 * identical body posted 3 seconds apart, only one tool_use event in the
 * agent log. the second post is always redundant and clutters the PR thread.
 *
 * we key on (comment_id, bodyWithFooter) so a legitimate follow-up reply
 * with different content still goes through. within a single run the
 * footer is constant (workflow run + model + jobId), so byte-equal bodies
 * catch the stutter without blocking real follow-ups.
 *
 * mirrors the shape of `duplicateReviewDecision` in mcp/review.ts.
 */
export function duplicateReplyDecision(params: {
  existing: { commentId: number; url: string | undefined; bodyWithFooter: string } | undefined;
  bodyWithFooter: string;
}): DuplicateReplyDecision | null {
  const existing = params.existing;
  if (!existing) return null;
  if (existing.bodyWithFooter !== params.bodyWithFooter) return null;
  return {
    kind: "already-replied",
    commentId: existing.commentId,
    url: existing.url,
    reason: `reply ${existing.commentId} with identical body was already posted in this session; ignoring duplicate call`,
  };
}

/**
 * a single-comment dispatch seeds the progress comment as a reply in the thread
 * the user pinged. once the agent answers that thread itself, the run summary
 * there is a second, redundant reply — drop it, as create_pull_request_review
 * drops the progress comment its review supersedes.
 */
async function deleteProgressReplyInThread(
  ctx: ToolContext,
  threadRootId: number | undefined
): Promise<void> {
  const progress = ctx.toolState.progressComment;
  if (
    progress.status !== "open" ||
    progress.comment.type !== "review" ||
    threadRootId === undefined
  ) {
    return;
  }
  const seeded = await ctx.octokit.rest.pulls.getReviewComment({
    owner: ctx.repo.owner,
    repo: ctx.repo.name,
    comment_id: progress.comment.id,
  });
  if (seeded.data.in_reply_to_id !== threadRootId) return;
  await deleteProgressComment(ctx);
}

export function ReplyToReviewCommentTool(ctx: ToolContext) {
  return tool({
    name: "reply_to_review_comment",
    mutates: true,
    description:
      "Reply to a PR review comment thread (NOT issue comments — this only works for inline review comments on PR diffs). " +
      'Example: `reply_to_review_comment({ pull_number: 1234, comment_id: 567890, body: "Fixed by adding a null check." })`. ' +
      "Call exactly ONCE per parent comment you address in AddressReviews mode — duplicate calls with the same body are a no-op. Keep replies extremely brief (1 sentence max).",
    parameters: ReplyToReviewComment,
    execute: execute(async ({ pull_number, comment_id, body }) => {
      const bodyWithFooter = addFooter(ctx, body);

      // guard against duplicate reply submissions in the same session.
      // see duplicateReplyDecision for the rationale.
      const dup = duplicateReplyDecision({
        existing: ctx.toolState.reviewReplies?.get(comment_id),
        bodyWithFooter,
      });
      if (dup) {
        log.info(`skipping duplicate review reply: ${dup.reason}`);
        return {
          success: true,
          skipped: true,
          reason: dup.reason,
          commentId: dup.commentId,
          url: dup.url,
        };
      }

      const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
        owner: ctx.repo.owner,
        repo: ctx.repo.name,
        pull_number,
        comment_id,
        body: bodyWithFooter,
      });
      log.info(`» created review comment ${result.data.id} (in reply to ${comment_id})`);

      // mark progress as updated so error reporting + run-result handling know
      // a substantive write happened (used by reportErrorToComment / handleAgentResult)
      ctx.toolState.wasUpdated = true;

      // record this reply for in-session dedupe of subsequent identical calls.
      ctx.toolState.reviewReplies ??= new Map();
      ctx.toolState.reviewReplies.set(comment_id, {
        commentId: result.data.id,
        url: result.data.html_url,
        bodyWithFooter,
      });

      // the reply already landed; failing to tidy the progress comment must not fail it.
      await deleteProgressReplyInThread(ctx, result.data.in_reply_to_id).catch((error) => {
        log.debug(`progress reply cleanup failed: ${error}`);
      });

      return {
        success: true,
        commentId: result.data.id,
        url: result.data.html_url,
        body: result.data.body,
        in_reply_to_id: result.data.in_reply_to_id,
      };
    }, "reply_to_review_comment"),
  });
}