Files
PaperClipAI/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts
T
DottaandClaude Opus 5.5 5498db1072 fix(interactions): relay a verified reply to a self-requested human-only confirmation (#19243)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents ask users to approve proposals and plans with confirmation
cards, and users often answer in the conversation
> - `resolve-from-comment` lets the responding agent record that
verified reply on the card
> - Native agents often create their own proposal or plan confirmation
as `human_only`, because the `request_human_input` schema offers only
that value
> - `resolve-from-comment` then refuses with `interaction_human_only`,
and the plan and onboarding gates keep the task in `in_review` or
`blocked`, although the user explicitly approved
> - This pull request adds one narrow relay for that case and keeps
every other human-only gate on its card
> - The benefit is that an explicit conversational approval works again,
and governed, company-capped and quality-review approvals keep their
existing controls

## Linked Issues or Issue Description

Refs #19073

**What happened?**
TestOps launch https://paperclip.testops.cloud/launch/2694 (GitHub run
https://github.com/paperclipai/paperclip/actions/runs/41915965118)
failed three Product regression cases for the same reason:
`continuation/revision-preserves-approval`,
`first-task/clarify-propose-accept` and `first-task/revise-accept`. In
each case the agent created a `request_confirmation` with
`resolverPolicy: human_only` (`effectiveResolverPolicySource:
requested`). The user replied "I approve…" or "Yes, I accept that
proposal". The agent's `resolve-from-comment` call returned 403
`interaction_human_only`. The task stayed `in_review` (plan gate) or
became `blocked` (onboarding decision gate).

**Expected behavior**
When the agent itself asked for a human-only answer, the verified latest
reply of the user it serves is that human answer. The agent can record
it. Company-capped, governed-action, issue-review and quality-review
gates still require the human to use the card.

**Steps to reproduce**
1. A native agent calls `request_human_input` with `interactionKind:
confirmation` and `resolverPolicy: human_only`.
2. The conversation user posts an explicit approval comment.
3. The agent calls `POST
/api/issues/{id}/interactions/{interactionId}/resolve-from-comment`.
Before this change it gets 403.

## What Changed

- `issue-thread-interaction-resolution.ts`: the agent actor gets an
optional `relayedHumanUserId`. A human-only decision is allowed as
`allow_relayed_human` only when all of these are true:
- the stored policy is `human_only`, its source is `requested` and its
provenance is `explicit`
  - the resolving agent created the card (`createdByAgentId` matches)
  - no governed action applies
  - no additional restriction (issue review) applies
  - any named addressee user is the relayed user

Company caps, the `governed_action` source and legacy rows with an
unknown source are still denied.
- `confirmation-comment-resolution.ts`: the service sets
`relayedHumanUserId` only after its existing comment checks pass:
user-authored, not agent-attributed, posted after the card, the latest
user message, the same conversation session. It also requires that the
author is the card's addressee, the conversation user or the run's
responsible user, that the card is not a quality review, and that the
company's current cap for the kind is not `human_only` (a tied cap keeps
the source `requested`). The same actor goes into the locked
accept/reject re-check. The activity entry adds `resolverPolicy:
"human_only"` and `resolution: "relayed_human_decision"`.
- `routes/issues.ts`: the route pre-check passes the candidate comment
author. The service still re-verifies everything under its locks.
- `issue-thread-interactions.ts`: carries the relay field into the
service's own audience re-check.
- Docs: the first-task skill and `api-reference.md` describe the narrow
relay. The guidance still says not to add `human_only` to ordinary
proposals.

## Verification

- `server/src/__tests__/confirmation-comment-resolution.test.ts`
(embedded Postgres): 72 pass.
- Green: relay through a conversation user and through a run's
responsible user. The card is `accepted`, `resolvedByAgentId` and
`resolvedByRunId` are set, `resolvedByUserId` stays null, and the audit
records `relayed_human_decision`.
- Negative controls, each with the card still pending and no audit:
agent-authored comment, earlier comment, wrong user, unbound issue/run,
`company_cap` source, `governed_action` source, `qualityReview`,
`toolAction`, `connectionAuthorization`, issue-review `human_only`
restriction, another addressed user, human-created card, another agent's
card, inherited company default, and a real capped card created through
`svc.create` with a tied cap. The tied-cap test fails as expected when
the cap is removed.
- Existing negative controls are unchanged, including `human_only`
without a bound user and the "rechecks narrowed permissions" retry.
- Red check: with the three service files reverted to `master`, the two
new green tests fail and the negative controls pass.
- `server/src/services/issue-thread-interaction-resolution.test.ts`: 21
pass, including the pure evaluator relay rules.
-
`server/src/services/native-runtime/confirmation-comment-api.integration.test.ts`:
11 pass. The real route accepts the relayed reply and refuses another
user's comment with 403.
- Also pass: `issue-thread-interaction-routes`,
`onboarding-first-task-admission`, `paperclip-runner-tool-authority`,
`runner-api`, `onboarding-first-task-assets` and
`company-skills-service` (2,474 tests in total).
- Changed files typecheck cleanly.
- Live verification: Product regression single-case runs of the three
cases are pending after merge.

## Risks

- This touches an approval gate. The relay is limited to cards whose
creator chose `human_only`. All existing reply-evidence checks still
apply, plus a bound-user requirement. Company caps, governed actions,
quality reviews, issue-review restrictions and named addressees still
deny the relay.
- Attribution does not change: the resolver is still the agent and run.
The human's comment is kept as `result.commentId` and in the audit.
- Legacy rows with a null policy source are not relayed.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use and
code execution.

## 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
#` / `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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 08:31:31 -06:00

209 lines
17 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { heartbeatRuns, issueComments, issues, issueThreadInteractions, nativeRunFinalizations } from "@paperclipai/db";
import { createLocalAgentJwt } from "../../agent-auth-jwt.js";
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
import { initializeRunIdentity } from "../run-identity.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
describe("confirmation reply through the native API tool", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); }, 60_000);
afterAll(async () => { await server?.close(); if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret; });
async function proposal(
f: Awaited<ReturnType<typeof server.fixture>>,
idempotencyKey: string,
prompt: string,
resolverPolicy?: "human_only",
) {
const result = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: {
idempotencyKey, interactionKind: "confirmation", title: "Approve child task", prompt,
continuationPolicy: "wake_assignee", ...(resolverPolicy === undefined ? {} : { resolverPolicy }),
payload: { version: 1, supersedeOnUserComment: false, acceptLabel: "Approve", rejectLabel: "Revise", rejectRequiresReason: true },
} });
const [card] = await server.db.select().from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, f.companyId), eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
));
if (!card) throw new Error("Native proposal did not persist its confirmation");
expect(result).toMatchObject({ disposition: "applied", interaction: { id: card.id, status: "pending" } });
return card;
}
async function seed(mode: "standard" | "planning" | "ask" = "standard", resolverPolicy?: "human_only") {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true, mode });
const card = await proposal(f, "first-task-proposal-confirmation", "Approve creating one child task to write the welcome note?", resolverPolicy);
const [comment] = await server.db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: f.userId, body: "Yes, write it." }).returning();
const call = { tool: "call_api", callId: randomUUID(), arguments: { operationId: "POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment", pathParams: { id: f.issueId, interactionId: card.id }, body: { commentId: comment!.id, decision: "accept" } } };
return { f, card, comment: comment!, call };
}
it("advertises and executes the real authenticated route, including retry", async () => {
const { f, card, comment, call } = await seed();
expect(card).toMatchObject({ requestedResolverPolicy: "anyone", effectiveResolverPolicy: "anyone", resolverPolicyProvenance: "inherited" });
const first = await f.authority.execute(call);
expect(first).toMatchObject({ status: 200, data: { deduplicated: false, interaction: {
id: card.id, status: "accepted", result: { outcome: "accepted", commentId: comment.id },
resolvedByAgentId: f.agentId, resolvedByRunId: f.runId, resolvedByUserId: null,
} } });
const retry = await f.authority.execute({ ...call, callId: randomUUID() });
expect(retry).toMatchObject({ status: 200, data: { deduplicated: true } });
const snapshot = await f.snapshot();
expect(snapshot.activity.filter(row => row.action === "issue.thread_interaction_accepted")).toHaveLength(1);
});
it("relays the conversation user's reply to a self-requested human-only card, but not another user's", async () => {
const { f, card, call, comment } = await seed("standard", "human_only");
expect(card).toMatchObject({ requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only",
effectiveResolverPolicySource: "requested", resolverPolicyProvenance: "explicit" });
expect(await f.authority.execute(call)).toMatchObject({ status: 200, data: { interaction: {
id: card.id, status: "accepted", result: { commentId: comment.id }, resolvedByAgentId: f.agentId, resolvedByUserId: null } } });
const snapshot = await f.snapshot();
expect(snapshot.activity.filter(row => row.action === "issue.thread_interaction_accepted"))
.toMatchObject([{ details: { resolverPolicy: "human_only", resolution: "relayed_human_decision" } }]);
const intruder = await seed("standard", "human_only");
await server.db.update(issueComments).set({ authorUserId: "somebody-else" }).where(eq(issueComments.id, intruder.comment.id));
expect(await intruder.f.authority.execute(intruder.call)).toMatchObject({ status: 403 });
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, intruder.card.id)))[0])
.toMatchObject({ status: "pending", result: null });
const other = await seed();
const foreignAnswer = { ...other.call, arguments: { ...other.call.arguments, body: { ...other.call.arguments.body, commentId: comment.id } } };
expect(await other.f.authority.execute(foreignAnswer)).toMatchObject({ status: 422 });
});
it("records acceptance on the revised native proposal without accepting its superseded predecessor", async () => {
const { f, card: original } = await seed();
const revised = await proposal(f, "first-task-proposal-confirmation-revised", "Approve creating one child task to write the revised Sunday welcome note?");
const [comment] = await server.db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
authorType: "user", authorUserId: f.userId, body: "Yes, I accept the revised Sunday proposal. Please do it." }).returning();
const call = { tool: "call_api", callId: randomUUID(), arguments: {
operationId: "POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment",
pathParams: { id: f.issueId, interactionId: original.id }, body: { commentId: comment!.id, decision: "accept" },
} };
expect(await f.authority.execute(call)).toMatchObject({ status: 409 });
expect(await f.authority.execute({ ...call, callId: randomUUID(), arguments: {
...call.arguments, pathParams: { ...call.arguments.pathParams, interactionId: revised.id },
} })).toMatchObject({ status: 200, data: { interaction: { id: revised.id, status: "accepted", result: { commentId: comment!.id },
resolvedByAgentId: f.agentId, resolvedByRunId: f.runId, resolvedByUserId: null } } });
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, original.id)))[0]).toMatchObject({
status: "expired", result: { outcome: "superseded_by_newer_request", supersededByInteractionId: revised.id },
});
});
it("retains a newer native response turn when it rejects an older proposal to revise it", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
await server.db.update(issues).set({ conversationAgentId: null, conversationUserId: null,
conversationState: null }).where(eq(issues.id, f.issueId));
const original = await proposal(f, "first-task-proposal-confirmation", "Approve the Saturday welcome note?");
const nativeSessionId = randomUUID();
await server.db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date(), nativeSessionId })
.where(eq(heartbeatRuns.id, f.runId));
const [comment] = await server.db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
authorType: "user", authorUserId: f.userId,
body: "Change the note to our Sunday meetup. Please revise your proposal; I have not accepted it yet." }).returning();
const runId = randomUUID();
await server.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId,
status: "running", responsibleUserId: f.userId, runtimeMode: "native", nativeIssueId: f.issueId, nativeSessionId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: f.issueId, commentId: comment!.id } });
await initializeRunIdentity(server.db, { companyId: f.companyId, runId, issueId: f.issueId,
responsibleUserId: f.userId!, cause: "instruction" });
const lockedAt = new Date();
const lease = { runId, companyId: f.companyId, issueId: f.issueId, phase: "observed", attempt: 1,
leaseOwner: `response-${randomUUID()}`, leaseExpiresAt: new Date(lockedAt.getTime() + 20 * 60_000),
controllerBootId: randomUUID(), controllerPid: process.pid, controllerProcessStartedAt: lockedAt,
controllerGeneration: 1 };
await server.db.insert(nativeRunFinalizations).values(lease);
await server.db.update(issues).set({ status: "in_review", executionRunId: runId, checkoutRunId: runId,
executionLockedAt: lockedAt, executionAgentNameKey: "api-eval-agent" }).where(eq(issues.id, f.issueId));
const current = new PaperclipRunnerToolAuthority(server.db, { ...f.authority.binding, runId });
await expect(current.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} })).resolves.toBeDefined();
const rejected = await current.execute({ tool: "call_api", callId: randomUUID(), arguments: {
operationId: "POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment",
pathParams: { id: f.issueId, interactionId: original.id }, body: { commentId: comment!.id, decision: "reject",
reason: "User requested a revised proposal for the Sunday meetup." },
} });
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, original.id)))[0])
.toMatchObject({ status: "rejected", sourceRunId: f.runId, resolvedByRunId: runId, result: { commentId: comment!.id } });
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
.toMatchObject({ status: "in_review", executionRunId: runId, checkoutRunId: runId,
executionLockedAt: lockedAt, executionAgentNameKey: "api-eval-agent" });
expect(rejected).toMatchObject({ status: 200 });
await expect(current.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} })).resolves.toBeDefined();
// The next attempted tool in the retained failure used this confirmation shape.
const next = await current.execute({ tool: "request_human_input", callId: randomUUID(), arguments: {
title: "Approve revised welcome-note task",
prompt: "Approve creating exactly one child task to write a two-sentence garden club welcome note for our free Sunday meetup and save it as a task document?",
payload: { version: 1, acceptLabel: "Approve", rejectLabel: "Revise", rejectRequiresReason: true, supersedeOnUserComment: false },
idempotencyKey: "first-task-proposal-confirmation-garden-welcome-note-sunday-v2",
interactionKind: "confirmation", continuationPolicy: "wake_assignee",
} });
expect(next).toMatchObject({ disposition: "applied", interaction: { status: "pending", sourceRunId: runId } });
expect((await server.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)))[0])
.toMatchObject(lease);
expect((await f.snapshot()).activity.filter(row => row.action === "issue.thread_interaction_rejected")).toHaveLength(1);
});
it("still requeues rejection of a proposal owned by the current response run", async () => {
const { f, card, call } = await seed();
await server.db.update(issues).set({ status: "in_review", checkoutRunId: f.runId,
executionLockedAt: new Date() }).where(eq(issues.id, f.issueId));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId);
const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/interactions/${card.id}/resolve-from-comment`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...call.arguments.body, decision: "reject", reason: "Revise this proposal." }),
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ interaction: { id: card.id, status: "rejected" } });
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
.toMatchObject({ status: "todo", executionRunId: null, checkoutRunId: null, executionLockedAt: null });
});
it("preserves human-only native completion-review rejection without ordinary continuation", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const service = issueThreadInteractionService(server.db);
const card = await service.create({ id: f.issueId, companyId: f.companyId }, {
kind: "request_confirmation", sourceRunId: f.runId, resolverPolicy: "human_only", continuationPolicy: "none",
payload: { version: 1, prompt: "Review the completed work.",
target: { type: "custom", key: "native_completion_review", revisionId: randomUUID() } },
}, { agentId: f.agentId, runId: f.runId });
await server.db.update(issues).set({ status: "in_review", checkoutRunId: f.runId,
executionLockedAt: new Date() }).where(eq(issues.id, f.issueId));
expect(await service.rejectInteraction({ id: f.issueId, companyId: f.companyId }, card.id,
{ reason: "The work needs revision." }, { userId: f.userId }))
.toMatchObject({ status: "rejected", effectiveResolverPolicy: "human_only", resolvedByUserId: f.userId });
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
.toMatchObject({ status: "todo", executionRunId: null, checkoutRunId: null, executionLockedAt: null });
});
it.each([false, true])("preserves human rejection requeueing with a newer response owner=%s", async (hasNewOwner) => {
const { f, card } = await seed();
const runId = hasNewOwner ? randomUUID() : null;
if (runId) await server.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId,
agentId: f.agentId, status: "running", runtimeMode: "native", nativeIssueId: f.issueId,
invocationSource: "assignment", triggerDetail: "system" });
await server.db.update(issues).set({ status: "in_review", executionRunId: runId,
checkoutRunId: runId, executionLockedAt: runId ? new Date() : null }).where(eq(issues.id, f.issueId));
const rejected = await issueThreadInteractionService(server.db).rejectInteraction(
{ id: f.issueId, companyId: f.companyId }, card.id, { reason: "Please revise the proposal." }, { userId: f.userId });
expect(rejected).toMatchObject({ status: "rejected", resolvedByUserId: f.userId });
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
.toMatchObject({ status: "todo", executionRunId: null, checkoutRunId: null, executionLockedAt: null });
});
it("records a conversational decision in Plan mode without opening writes in Ask mode", async () => {
const planning = await seed("planning");
expect(await planning.f.authority.execute(planning.call)).toMatchObject({ status: 200, data: { interaction: { status: "accepted" } } });
const ask = await seed("ask");
await expect(ask.f.authority.execute(ask.call)).rejects.toThrow("only reads");
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, ask.card.id)))[0]?.status).toBe("pending");
});
it("rejects unauthenticated HTTP requests", async () => {
const { f, card, call, comment } = await seed();
const url = `${server.apiUrl}/api/issues/${f.issueId}/interactions/${card.id}/resolve-from-comment`;
expect((await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(call.arguments.body) })).status).toBe(404);
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId);
expect((await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ ...call.arguments.body, actorUserId: f.userId }) })).status).toBe(400);
});
it("preserves formal approval authority in the HTTP route", async () => {
const { f } = await seed();
expect(await f.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: {
operationId: "POST /api/approvals/{id}/approve", pathParams: { id: f.approvalId }, body: {},
} })).toMatchObject({ ok: false, status: 403 });
expect((await f.snapshot()).approvals.find(row => row.id === f.approvalId)?.status).toBe("pending");
});
});