Files
PaperClipAI/server/src/services/tool-action-replay-intent.ts
T
DottaandPaperclip a2a6129ed5 Allow a new human intent to request tool review after a decline (#18742)
## Thinking Path

> - Paperclip manages work for companies of AI agents.
> - App writes can require a human review for each invocation.
> - Decline and Always allow are separate controls.
> - The gateway currently reuses a declined action across later human
requests with the same arguments.
> - A new request can therefore receive the old decline instead of a new
review.
> - This change uses the saved run and human comment to distinguish a
new intent.
> - The new intent still follows the current policy and approval gates.

## Linked Issues or Issue Description

**What happened?**

After a person declines an Ask first app write, a later request from
that person for the same write receives `action_declined`. The gateway
matches company, task, agent, tool and arguments across runs. It does
not distinguish the later human request.

**Expected behavior**

A distinct later request from the same responsible person can open a new
Ask first review. It must not execute the write or reuse approval. A
retry of the declined intent, including its rejection continuation, must
remain declined.

**Steps to reproduce**

1. Ask an agent to perform an app write that requires approval.
2. Decline the proposed action.
3. Send a new request for the same action.
4. Observe that the gateway reuses the old rejection and creates no new
pending review.

**Paperclip version or commit**

The behavior is present on master `7b8aa60b6d` and in a retained native
journey. The focused parent control reproduces the matching error. No
related open fix was found in the scoped PR search.

## What Changed

- Only a rejected match can be released for a later human intent.
- Require the active run, company, task, agent and responsible person to
match the saved direct-human comment. Reject derived, deleted, untrusted
or system-authored input and invalid chronology.
- Preserve all other replay states, signed argument checks, current
policy, unresolved-effect guards, approval and execution owners.
- Document the distinction between decline and a standing policy change.

## Verification

- Actual matching-function control: parent fails; this change passes.
The control uses the unchanged owner body with synthetic database rows.
It does not claim real PostgreSQL or provider verification.
- 52 focused pure controls pass. These cover same-intent retries,
rejection continuations, foreign scope and actor, invalid authorship,
chronology and other action states.
- TypeScript 7.0.2 checks pass for the new helper and its owning tests.
- `node scripts/check-maps.mjs` and `git diff --check` pass.
- The existing gateway service suite has a new signed-action integration
test. It covers ordinary and reopened human requests. It requires the
old rejection plus one new pending request, a stable retry, and no
completed call. Each leaf prints one fixed success label after its
assertions so CI can distinguish execution from a platform skip. No
local PostgreSQL, app, model or provider execution was run. Current-head
normal CI41594912212 completed with48 successful jobs and1 expected
skip. Both real PostgreSQL leaves executed and emitted their
post-assertion markers; the owning job passed. Full typecheck/build
checks passed in CI.

## Risks

This adds one indexed run/comment read only after a rejected matching
action. Missing identity or ambiguous provenance keeps the old
rejection. Legacy and external comments without direct-human provenance
do not reset it. A new human intent is not consent to execute. This core
PR remains unmerged. Candidate native journey qualification is separate;
the original failed journey is retained.

## Model Used

OpenAI `gpt-6.1-sol` with reasoning, source inspection and tool use. The
host did not expose the context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#123` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-03 06:28:36 -06:00

61 lines
4.1 KiB
TypeScript

/** A fresh human request may ask for another review, never inherit approval.
* These values come only from persisted run/comment rows, not tool arguments. */
interface ReplaySession {
companyId: string; agentId: string | null; issueId: string | null; runId: string | null;
responsibleUserId?: string | null;
}
interface ReplayRun {
id: string; companyId: string; agentId: string; status: string; responsibleUserId: string | null;
startedAt: Date | null; issueId?: string | null; nativeIssueId?: string | null;
contextSnapshot: Record<string, unknown> | null;
}
interface HumanComment {
id: string; companyId: string; issueId: string; authorType: string | null;
authorUserId: string | null; authorAgentId: string | null; createdByRunId: string | null;
derivedAuthorAgentId: string | null; derivedCreatedByRunId: string | null; derivedAuthorSource: string | null;
deletedAt: Date | null; sourceTrust: unknown; presentation: { kind?: string } | null;
createdAt: Date; body: string;
}
interface RejectedAction {
companyId: string; issueId: string | null; status: string; requestedByAgentId: string | null;
resolvedByUserId: string | null; decidedByUserId: string | null;
resolvedByAgentId: string | null; decidedByAgentId: string | null;
resolvedAt: Date | null; decidedAt: Date | null;
}
interface Invocation {
companyId: string; issueId: string | null; agentId: string | null; runId: string | null;
}
export function isNewHumanIntentAfterToolRejection(input: {
session: ReplaySession; rejection: RejectedAction; invocation: Invocation;
run: ReplayRun; comment: HumanComment;
}): boolean {
const { session, rejection, invocation, run, comment } = input;
if (rejection.status !== "rejected" || !session.runId || !session.issueId || !session.agentId || !session.responsibleUserId
|| rejection.companyId !== session.companyId || rejection.issueId !== session.issueId || rejection.requestedByAgentId !== session.agentId
|| invocation.companyId !== session.companyId || invocation.issueId !== session.issueId || invocation.agentId !== session.agentId
|| !invocation.runId || invocation.runId === session.runId
|| run.id !== session.runId || run.companyId !== session.companyId || run.agentId !== session.agentId
|| run.status !== "running" || run.responsibleUserId !== session.responsibleUserId) return false;
const context = run.contextSnapshot;
if (!context || (context.source !== "issue.comment" && context.source !== "issue.comment.reopen") || context.commentId !== comment.id
|| context.issueId !== session.issueId
|| (context.taskId != null && context.taskId !== session.issueId)
|| (run.issueId != null && run.issueId !== session.issueId)
|| (run.nativeIssueId != null && run.nativeIssueId !== session.issueId)) return false;
if (comment.companyId !== session.companyId || comment.issueId !== session.issueId
|| comment.authorType !== "user" || comment.authorUserId !== session.responsibleUserId
|| comment.authorAgentId || comment.createdByRunId || comment.derivedAuthorAgentId || comment.derivedCreatedByRunId
|| comment.derivedAuthorSource || comment.deletedAt || comment.sourceTrust != null
|| comment.presentation?.kind === "system_notice" || !comment.body.trim() || comment.body.trim() === "/new") return false;
// A later request by a different person cannot override this person's decline.
const deciders = [rejection.decidedByUserId, rejection.resolvedByUserId].filter(value => value != null);
if (!deciders.length || deciders.some(value => value !== session.responsibleUserId)
|| rejection.resolvedByAgentId || rejection.decidedByAgentId) return false;
const decisions = [rejection.decidedAt, rejection.resolvedAt].filter((value): value is Date => value != null);
const times = decisions.map(value => value.getTime());
const requestedAt = comment.createdAt.getTime(), startedAt = run.startedAt?.getTime();
return times.length > 0 && times.every(Number.isFinite) && Number.isFinite(requestedAt) && Number.isFinite(startedAt)
&& Math.max(...times) < requestedAt && requestedAt <= startedAt! && startedAt! <= Date.now();
}