mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## 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>
321 lines
27 KiB
TypeScript
321 lines
27 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { activityLog, agents, authUsers, companies, companyMemberships, createDb, documents, documentRevisions, heartbeatRuns,
|
|
issueComments, issueDocuments, issues, issueThreadInteractions } from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js";
|
|
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
|
import { pendingNativeGovernance } from "../services/native-runtime/native-run-finalizer.js";
|
|
import { getConversationConfirmationContext } from "../services/conversation-confirmation-context.js";
|
|
|
|
const { resolvedTelemetry } = vi.hoisted(() => ({ resolvedTelemetry: vi.fn() }));
|
|
vi.mock("@paperclipai/shared/telemetry", async importOriginal => ({
|
|
...await importOriginal<Record<string, unknown>>(), trackInteractionResolved: resolvedTelemetry, trackInteractionCreated: vi.fn(),
|
|
}));
|
|
vi.mock("../telemetry.js", async importOriginal => ({
|
|
...await importOriginal<Record<string, unknown>>(), getTelemetryClient: () => ({}),
|
|
}));
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("conversational confirmation resolution", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => { temporary = await startEmbeddedPostgresTestDatabase("confirmation-replies-"); db = createDb(temporary.connectionString); }, 30_000);
|
|
afterAll(async () => { await temporary?.cleanup(); });
|
|
async function seed(checkbox = false, conversation = false) {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Replies", issuePrefix: companyId.slice(0, 8) });
|
|
if (conversation) {
|
|
await db.insert(authUsers).values({ id: "operator", name: "Operator", email: "operator@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "operator", status: "active", membershipRole: "member" });
|
|
}
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Planner", adapterType: "paperclip_runner" });
|
|
const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Proposal", status: "in_progress", assigneeAgentId: agentId,
|
|
...(conversation ? { conversationAgentId: agentId, conversationUserId: "operator", conversationState: "active", conversationSessionGeneration: 1 } : {}) }).returning();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, status: "running", runtimeMode: "native", contextSnapshot: { issueId, conversationSessionGeneration: 1 } });
|
|
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
|
const svc = issueThreadInteractionService(db);
|
|
const card = await svc.create(issue, checkbox ? {
|
|
kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Approve selected work?", options: [{ id: "note", label: "Welcome note" }, { id: "poster", label: "Poster" }], minSelected: 1, defaultSelectedOptionIds: ["poster"] },
|
|
} : { kind: "request_confirmation", payload: { version: 1, prompt: "Approve the welcome note?" } }, { agentId });
|
|
const [comment] = await db.insert(issueComments).values({ companyId, issueId, authorUserId: "operator", authorType: "user", body: "Yes, write the welcome note." }).returning();
|
|
const args = { companyId, issueId, interactionId: card.id, actor: { agentId, runId }, input: { commentId: comment.id, decision: "accept" as const, ...(checkbox ? { selectedOptionIds: ["note"] } : {}) } };
|
|
return { args, card, issue, comment, svc, companyId, issueId, agentId, runId };
|
|
}
|
|
const readCard = (id: string) => db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, id)).then(rows => rows[0]!);
|
|
const audit = (id: string) => db.select().from(activityLog).where(eq(activityLog.entityId, id)).then(rows => rows.filter(row => row.details?.source === "conversation_reply"));
|
|
|
|
it.each(["ask_user_questions", "request_confirmation", "request_checkbox_confirmation"])("an older chat %s stays pending without gating a later reply", async kind => {
|
|
const f = await seed(false, true);
|
|
await db.update(issueThreadInteractions).set({ kind, sourceRunId: null }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
expect(await pendingNativeGovernance({ db, ...f, executionState: null })).toBeNull();
|
|
expect((await readCard(f.card.id)).status).toBe("pending");
|
|
await db.update(issueThreadInteractions).set({ sourceRunId: f.runId }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
// A later human message makes ordinary questions historical even when
|
|
// they came from the current run; confirmations still require a decision.
|
|
expect(await pendingNativeGovernance({ db, ...f, executionState: null }))
|
|
.toEqual(kind === "ask_user_questions" ? null : { kind: "interaction", id: f.card.id });
|
|
});
|
|
|
|
it.each(["ordinary-task", "human_only", "toolAction", "secretProposal", "connectionAuthorization"])("preserves the existing %s completion gate", async kind => {
|
|
const f = await seed(false, kind !== "ordinary-task");
|
|
if (kind === "human_only") await db.update(issueThreadInteractions).set({ effectiveResolverPolicy: "human_only" }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
else if (kind !== "ordinary-task") await db.update(issueThreadInteractions).set({ payload: { ...f.card.payload, [kind]: {} } }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
expect(await pendingNativeGovernance({ db, ...f, executionState: null })).toEqual({ kind: "interaction", id: f.card.id });
|
|
expect(await pendingNativeGovernance({ db, ...f, executionState: { status: "pending" } })).toEqual({ kind: "execution_stage", id: f.runId });
|
|
expect(await pendingNativeGovernance({ db, ...f, companyId: randomUUID(), executionState: null })).toBeNull();
|
|
});
|
|
|
|
it.each([true, false])("preserves historical questions only in agent chat (conversation=%s)", async conversation => {
|
|
const f = await seed(false, conversation);
|
|
const question = (prompt: string) => ({ kind: "ask_user_questions" as const, payload: { version: 1 as const,
|
|
supersedeOnUserComment: true, questions: [{ id: "color", prompt, selectionMode: "single" as const, required: true,
|
|
options: [{ id: "blue", label: "Blue" }, { id: "green", label: "Green" }] }] } });
|
|
const first = await f.svc.create(f.issue, question("Which color?"), { agentId: f.agentId });
|
|
await f.svc.expireRequestConfirmationsSupersededByComment(f.issue,
|
|
{ id: f.comment.id, authorUserId: "operator", createdAt: new Date(Date.now() + 1000) }, { userId: "operator" });
|
|
expect((await readCard(first.id)).status).toBe(conversation ? "pending" : "expired");
|
|
const second = await f.svc.create(f.issue, question("Which shade?"), { agentId: f.agentId });
|
|
await f.svc.create(f.issue, question("Which finish?"), { agentId: f.agentId });
|
|
expect((await readCard(second.id)).status).toBe(conversation ? "pending" : "expired");
|
|
if (conversation) {
|
|
const answered = await f.svc.answerQuestions(f.issue, first.id, { answers: [{ questionId: "color", optionIds: ["blue"] }] }, { userId: "operator" });
|
|
expect(answered).toMatchObject({ status: "answered", result: { answers: [{ questionId: "color", optionIds: ["blue"] }] } });
|
|
expect((await readCard(second.id)).status).toBe("pending");
|
|
await expect(f.svc.answerQuestions(f.issue, first.id, { answers: [{ questionId: "color", optionIds: ["green"] }] }, { userId: "operator" })).rejects.toThrow();
|
|
}
|
|
});
|
|
|
|
it("supplies actual pending card identities and explicit choices, then refreshes after resolution", async () => {
|
|
const f = await seed(true, true);
|
|
const snapshot = await getConversationConfirmationContext({ db, ...f });
|
|
expect(snapshot).toMatchObject({ truncated: false, cards: [{ id: f.card.id, status: "pending", resolverPolicy: "anyone",
|
|
options: [{ id: "note", label: "Welcome note" }, { id: "poster", label: "Poster" }] }] });
|
|
expect(JSON.stringify(snapshot)).not.toContain("defaultSelectedOptionIds");
|
|
await resolveConfirmationFromComment(db, f.args);
|
|
expect(await getConversationConfirmationContext({ db, ...f })).toEqual({ truncated: false, cards: [] });
|
|
});
|
|
it.each(["ordinary-task", "other-company", "other-agent"])("does not supply confirmation context for %s", async kind => {
|
|
const f = await seed(false, kind !== "ordinary-task");
|
|
expect(await getConversationConfirmationContext({ db, ...f,
|
|
...(kind === "other-company" ? { companyId: randomUUID() } : {}),
|
|
...(kind === "other-agent" ? { agentId: randomUUID() } : {}),
|
|
})).toBeNull();
|
|
});
|
|
it.each(["toolAction", "secretProposal", "connectionAuthorization", "questions", "expired"])("excludes %s from ordinary confirmation context", async kind => {
|
|
const f = await seed(false, true);
|
|
await db.update(issueThreadInteractions).set(kind === "questions" ? { kind: "ask_user_questions" }
|
|
: kind === "expired" ? { status: "expired" }
|
|
: { payload: { ...f.card.payload, [kind]: { value: "must-not-leak" } } }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
expect(await getConversationConfirmationContext({ db, ...f })).toEqual({ truncated: false, cards: [] });
|
|
});
|
|
it("excludes prior-session cards and preserves the current card's human-only policy", async () => {
|
|
const f = await seed(false, true);
|
|
const boundaryAt = new Date(Date.now() + 1000);
|
|
const [boundary] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
|
|
authorType: "user", authorUserId: "operator", body: "/new", createdAt: boundaryAt }).returning();
|
|
await db.update(issues).set({ conversationBoundaryCommentId: boundary.id }).where(eq(issues.id, f.issueId));
|
|
const [current] = await db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: f.issueId,
|
|
kind: "request_confirmation", effectiveResolverPolicy: "human_only", payload: { version: 1, prompt: "Current proposal" },
|
|
createdAt: new Date(boundaryAt.getTime() + 1000) }).returning();
|
|
expect(await getConversationConfirmationContext({ db, ...f })).toMatchObject({ cards: [{ id: current.id, resolverPolicy: "human_only" }] });
|
|
});
|
|
it("bounds card and proposal data and declares truncation instead of hiding it", async () => {
|
|
const f = await seed(false, true);
|
|
await db.update(issueThreadInteractions).set({ payload: { version: 1, prompt: "x".repeat(2500) } }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
await db.insert(issueThreadInteractions).values(Array.from({ length: 13 }, () => ({ companyId: f.companyId, issueId: f.issueId,
|
|
kind: "request_confirmation", payload: { version: 1 as const, prompt: "Another proposal" }, createdAt: new Date(Date.now() + 1000) })));
|
|
const snapshot = await getConversationConfirmationContext({ db, ...f });
|
|
expect(snapshot?.truncated).toBe(true);
|
|
expect(snapshot?.cards).toHaveLength(12);
|
|
expect(snapshot?.cards[0]?.prompt).toHaveLength(2000);
|
|
expect(snapshot?.cards[0]?.promptTruncated).toBe(true);
|
|
});
|
|
|
|
it.each([false, true])("persists acceptance, explicit selection and source-message audit (checkbox=%s)", async checkbox => {
|
|
const f = await seed(checkbox, true);
|
|
const answer = await resolveConfirmationFromComment(db, f.args);
|
|
expect(answer).toMatchObject({ deduplicated: false, interaction: { status: "accepted", resolvedByAgentId: f.agentId, resolvedByRunId: f.runId, resolvedByUserId: null, result: { outcome: "accepted", commentId: f.comment.id, ...(checkbox ? { selectedOptionIds: ["note"] } : {}) } } });
|
|
expect(await audit(f.issueId)).toMatchObject([{ action: "issue.thread_interaction_accepted", details: { responseCommentId: f.comment.id, responseUserId: "operator" } }]);
|
|
});
|
|
it.each(["accept", "reject"] as const)("does not emit a %s resolution when the provenance audit rolls back", async decision => {
|
|
const f = await seed();
|
|
await db.execute(sql.raw(`CREATE FUNCTION fail_confirmation_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.entity_id = '${f.issueId}' THEN RAISE EXCEPTION 'audit write failed'; END IF; RETURN NEW; END $$`));
|
|
await db.execute(sql.raw("CREATE TRIGGER fail_confirmation_audit BEFORE INSERT ON activity_log FOR EACH ROW EXECUTE FUNCTION fail_confirmation_audit()"));
|
|
resolvedTelemetry.mockClear();
|
|
try {
|
|
await expect(resolveConfirmationFromComment(db, { ...f.args, input: { commentId: f.comment.id, decision } })).rejects.toThrow();
|
|
expect((await readCard(f.card.id)).status).toBe("pending");
|
|
expect(await audit(f.issueId)).toHaveLength(0);
|
|
expect(resolvedTelemetry).not.toHaveBeenCalled();
|
|
} finally {
|
|
await db.execute(sql.raw("DROP TRIGGER fail_confirmation_audit ON activity_log"));
|
|
await db.execute(sql.raw("DROP FUNCTION fail_confirmation_audit()"));
|
|
}
|
|
await resolveConfirmationFromComment(db, { ...f.args, input: { commentId: f.comment.id, decision } });
|
|
expect(resolvedTelemetry).toHaveBeenCalledExactlyOnceWith(expect.anything(), expect.objectContaining({ status: decision === "accept" ? "accepted" : "rejected" }));
|
|
await resolveConfirmationFromComment(db, { ...f.args, input: { commentId: f.comment.id, decision } });
|
|
expect(resolvedTelemetry).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("records refusal and its reason on the card", async () => {
|
|
const f = await seed();
|
|
const answer = await resolveConfirmationFromComment(db, { ...f.args, input: { commentId: f.comment.id, decision: "reject", reason: "Not needed" } });
|
|
expect(answer.interaction).toMatchObject({ status: "rejected", result: { outcome: "rejected", reason: "Not needed", commentId: f.comment.id } });
|
|
});
|
|
it("serializes simultaneous retries into one decision and one audit record", async () => {
|
|
const f = await seed(true);
|
|
const answers = await Promise.all([resolveConfirmationFromComment(db, f.args), resolveConfirmationFromComment(db, f.args)]);
|
|
expect(answers.map(x => x.deduplicated).sort()).toEqual([false, true]);
|
|
expect(await audit(f.issueId)).toHaveLength(1);
|
|
const before = await readCard(f.card.id);
|
|
await resolveConfirmationFromComment(db, f.args);
|
|
expect(await readCard(f.card.id)).toEqual(before);
|
|
});
|
|
it("does not rewrite an opposite decision or a different checkbox selection", async () => {
|
|
const f = await seed(true);
|
|
await resolveConfirmationFromComment(db, f.args);
|
|
await expect(resolveConfirmationFromComment(db, { ...f.args, input: { commentId: f.comment.id, decision: "reject" } })).rejects.toThrow("different decision");
|
|
await expect(resolveConfirmationFromComment(db, { ...f.args, input: { ...f.args.input, selectedOptionIds: ["poster"] } })).rejects.toThrow("different decision");
|
|
expect((await readCard(f.card.id)).result).toMatchObject({ selectedOptionIds: ["note"] });
|
|
});
|
|
it.each(["deleted", "agent", "system", "agent-attributed", "quarantined", "before-card", "other-task", "other-company", "newer-message", "wrong-user", "reset-command"])("rejects %s answer evidence without effects", async kind => {
|
|
const f = await seed(false, true);
|
|
const change: Record<string, unknown> = kind === "deleted" ? { deletedAt: new Date() }
|
|
: kind === "agent" ? { authorType: "agent", authorAgentId: f.agentId }
|
|
: kind === "system" ? { authorType: "system" }
|
|
: kind === "agent-attributed" ? { createdByRunId: f.runId }
|
|
: kind === "quarantined" ? { sourceTrust: { preset: "low_trust", disposition: "quarantined" } }
|
|
: kind === "before-card" ? { createdAt: new Date(0) }
|
|
: kind === "wrong-user" ? { authorUserId: "somebody-else" }
|
|
: kind === "reset-command" ? { body: "/new" } : {};
|
|
if (kind === "other-task") {
|
|
const [otherIssue] = await db.insert(issues).values({ companyId: f.companyId, title: "Other proposal", status: "in_progress" }).returning();
|
|
const [otherComment] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: otherIssue.id, authorUserId: "operator", authorType: "user", body: "Yes, go ahead." }).returning();
|
|
f.args.input.commentId = otherComment.id;
|
|
} else if (kind === "other-company") {
|
|
const other = await seed();
|
|
f.args.input.commentId = other.comment.id;
|
|
} else if (kind === "newer-message") {
|
|
await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorUserId: "operator", authorType: "user", body: "Wait, do not proceed.", createdAt: new Date(Date.now() + 1000) });
|
|
} else await db.update(issueComments).set(change).where(eq(issueComments.id, f.comment.id));
|
|
await expect(resolveConfirmationFromComment(db, f.args)).rejects.toThrow();
|
|
expect((await readCard(f.card.id)).status).toBe("pending");
|
|
expect(await audit(f.issueId)).toHaveLength(0);
|
|
});
|
|
it.each(["cancelled", "finished", "lost-owner", "wrong-run-task", "reset-generation", "old-card"])("fences %s execution", async kind => {
|
|
const f = await seed(false, true);
|
|
if (kind === "cancelled" || kind === "finished") await db.update(heartbeatRuns).set({ status: kind === "cancelled" ? "cancelled" : "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
|
if (kind === "lost-owner") await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, f.issueId));
|
|
if (kind === "wrong-run-task") await db.update(heartbeatRuns).set({ nativeIssueId: null, contextSnapshot: {} }).where(eq(heartbeatRuns.id, f.runId));
|
|
if (kind === "reset-generation") await db.update(issues).set({ conversationSessionGeneration: 2 }).where(eq(issues.id, f.issueId));
|
|
if (kind === "old-card") {
|
|
const [boundary] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "operator", body: "/new" }).returning();
|
|
await db.update(issues).set({ conversationBoundaryCommentId: boundary.id }).where(eq(issues.id, f.issueId));
|
|
}
|
|
await expect(resolveConfirmationFromComment(db, f.args)).rejects.toThrow();
|
|
expect((await readCard(f.card.id)).status).toBe("pending");
|
|
});
|
|
it.each(["human_only", "not_creator", "addressee", "toolAction", "secretProposal", "connectionAuthorization", "questions", "closed"])("preserves %s restrictions", async kind => {
|
|
const f = await seed();
|
|
if (kind === "closed") await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
|
|
else if (kind === "human_only" || kind === "not_creator") await db.update(issueThreadInteractions).set({ effectiveResolverPolicy: kind }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
else if (kind === "addressee") await db.update(issueThreadInteractions).set({ addresseeUserId: "another-user" }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
else if (kind === "questions") await db.update(issueThreadInteractions).set({ kind: "ask_user_questions" }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
else await db.update(issueThreadInteractions).set({ payload: { ...f.card.payload, [kind]: {} } }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
await expect(resolveConfirmationFromComment(db, f.args)).rejects.toThrow();
|
|
expect((await readCard(f.card.id)).status).toBe("pending");
|
|
});
|
|
it.each(["human_only", "not_creator"] as const)("does not promote a user's yes through an additional %s review restriction", async policy => {
|
|
const f = await seed();
|
|
await expect(resolveConfirmationFromComment(db, { ...f.args, actor: { ...f.args.actor, resolverPolicyRestriction: policy } })).rejects.toThrow();
|
|
expect((await readCard(f.card.id)).status).toBe("pending");
|
|
expect(await audit(f.issueId)).toHaveLength(0);
|
|
});
|
|
// A card the agent itself requested as human_only records the bound user's
|
|
// verified latest reply. Every other human-only source still needs the card.
|
|
async function seedHumanOnly(binding: "conversation" | "run" | "unbound" = "conversation") {
|
|
const f = await seed(false, binding === "conversation");
|
|
await db.update(issueThreadInteractions).set({ effectiveResolverPolicy: "human_only", requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicySource: "requested", resolverPolicyProvenance: "explicit" }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
if (binding === "run") await db.update(heartbeatRuns).set({ responsibleUserId: "operator" }).where(eq(heartbeatRuns.id, f.runId));
|
|
return f;
|
|
}
|
|
it.each(["conversation", "run"] as const)("relays the %s user's verified reply to a self-requested human-only card", async binding => {
|
|
const f = await seedHumanOnly(binding);
|
|
await resolveConfirmationFromComment(db, f.args);
|
|
const card = await readCard(f.card.id);
|
|
expect(card).toMatchObject({ status: "accepted", resolvedByAgentId: f.agentId, resolvedByRunId: f.runId, resolvedByUserId: null,
|
|
result: { commentId: f.comment.id } });
|
|
expect(await audit(f.issueId)).toMatchObject([{ action: "issue.thread_interaction_accepted", agentId: f.agentId, runId: f.runId,
|
|
details: { responseCommentId: f.comment.id, responseUserId: "operator", resolverPolicy: "human_only", resolution: "relayed_human_decision" } }]);
|
|
});
|
|
it.each(["agent-comment", "earlier-comment", "wrong-user", "unbound", "company_cap", "governed_action", "qualityReview",
|
|
"toolAction", "connectionAuthorization", "review-restriction", "other-addressee", "human-created", "other-agent-created",
|
|
"inherited-default"])("keeps a human-only card on its card for %s", async kind => {
|
|
const f = await seedHumanOnly(kind === "unbound" ? "unbound" : "conversation");
|
|
const card = (set: Record<string, unknown>) => db.update(issueThreadInteractions).set(set).where(eq(issueThreadInteractions.id, f.card.id));
|
|
if (kind === "agent-comment") await db.update(issueComments).set({ authorType: "agent", authorAgentId: f.agentId }).where(eq(issueComments.id, f.comment.id));
|
|
if (kind === "earlier-comment") await db.update(issueComments).set({ createdAt: new Date(0) }).where(eq(issueComments.id, f.comment.id));
|
|
if (kind === "wrong-user") await db.update(issueComments).set({ authorUserId: "somebody-else" }).where(eq(issueComments.id, f.comment.id));
|
|
if (kind === "company_cap" || kind === "governed_action") await card({ effectiveResolverPolicySource: kind });
|
|
if (["qualityReview", "toolAction", "connectionAuthorization"].includes(kind)) await card({ payload: { ...f.card.payload, [kind]: {} } });
|
|
if (kind === "other-addressee") await card({ addresseeUserId: "somebody-else" });
|
|
if (kind === "human-created") await card({ createdByAgentId: null, createdByUserId: "operator", sourceRunId: null });
|
|
if (kind === "other-agent-created") {
|
|
const [other] = await db.insert(agents).values({ companyId: f.companyId, name: "Other", adapterType: "paperclip_runner" }).returning();
|
|
await card({ createdByAgentId: other.id, sourceRunId: null });
|
|
}
|
|
if (kind === "inherited-default") await card({ resolverPolicyProvenance: "inherited" });
|
|
const args = kind === "review-restriction" ? { ...f.args, actor: { ...f.args.actor, resolverPolicyRestriction: "human_only" as const } } : f.args;
|
|
await expect(resolveConfirmationFromComment(db, args)).rejects.toThrow();
|
|
expect((await readCard(f.card.id)).status).toBe("pending");
|
|
expect(await audit(f.issueId)).toHaveLength(0);
|
|
});
|
|
it("keeps a requested human-only card on its card when the company cap ties at human_only", async () => {
|
|
const f = await seed(false, true);
|
|
await db.update(companies).set({ interactionResolverGovernance: { request_confirmation: { cap: "human_only" } } }).where(eq(companies.id, f.companyId));
|
|
const capped = await f.svc.create(f.issue, { kind: "request_confirmation", resolverPolicy: "human_only",
|
|
payload: { version: 1, prompt: "Approve the capped proposal?" } }, { agentId: f.agentId });
|
|
expect(capped).toMatchObject({ effectiveResolverPolicy: "human_only", effectiveResolverPolicySource: "requested", resolverPolicyProvenance: "explicit" });
|
|
const [reply] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorUserId: "operator", authorType: "user",
|
|
body: "Yes, approved.", createdAt: new Date(Date.now() + 1000) }).returning();
|
|
await expect(resolveConfirmationFromComment(db, { ...f.args, interactionId: capped.id, input: { commentId: reply.id, decision: "accept" } }))
|
|
.rejects.toThrow();
|
|
expect((await readCard(capped.id)).status).toBe("pending");
|
|
expect(await audit(f.issueId)).toHaveLength(0);
|
|
});
|
|
it("rechecks narrowed permissions on an otherwise matching retry", async () => {
|
|
const f = await seed();
|
|
await resolveConfirmationFromComment(db, f.args);
|
|
await db.update(issueThreadInteractions).set({ effectiveResolverPolicy: "human_only" }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
await expect(resolveConfirmationFromComment(db, f.args)).rejects.toThrow();
|
|
expect((await readCard(f.card.id)).resolvedByUserId).toBeNull();
|
|
expect(await audit(f.issueId)).toHaveLength(1);
|
|
});
|
|
it("does not borrow checkbox defaults, unknown choices, or insufficient selections", async () => {
|
|
const f = await seed(true);
|
|
for (const selectedOptionIds of [undefined, [], ["unknown"], ["note", "note"]]) {
|
|
await expect(resolveConfirmationFromComment(db, { ...f.args, input: { ...f.args.input, selectedOptionIds } })).rejects.toThrow();
|
|
}
|
|
expect((await readCard(f.card.id)).status).toBe("pending");
|
|
});
|
|
it("does not bless a card already accepted through a different channel", async () => {
|
|
const f = await seed();
|
|
await f.svc.acceptInteraction(f.issue, f.card.id, {}, { userId: "operator" });
|
|
await expect(resolveConfirmationFromComment(db, f.args)).rejects.toThrow("different decision");
|
|
expect((await readCard(f.card.id)).resolvedByUserId).toBe("operator");
|
|
});
|
|
it("rejects an obsolete plan revision", async () => {
|
|
const f = await seed();
|
|
const documentId = randomUUID(), revisionId = randomUUID(), nextRevisionId = randomUUID();
|
|
await db.insert(documents).values({ id: documentId, companyId: f.companyId, latestBody: "New plan", latestRevisionId: nextRevisionId });
|
|
await db.insert(documentRevisions).values([{ id: revisionId, documentId, companyId: f.companyId, revisionNumber: 1, body: "Old plan" }, { id: nextRevisionId, documentId, companyId: f.companyId, revisionNumber: 2, body: "New plan" }]);
|
|
await db.insert(issueDocuments).values({ companyId: f.companyId, issueId: f.issueId, documentId, key: "plan" });
|
|
await db.update(issueThreadInteractions).set({ payload: { ...f.card.payload, target: { type: "issue_document", key: "plan", revisionId } } }).where(eq(issueThreadInteractions.id, f.card.id));
|
|
await expect(resolveConfirmationFromComment(db, f.args)).rejects.toThrow();
|
|
expect((await readCard(f.card.id)).status).not.toBe("accepted");
|
|
expect(await audit(f.issueId)).toHaveLength(0);
|
|
});
|
|
});
|