mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents ask a person to approve a governed tool action (for example, create a Notebook page) before the tool runs > - Approve means run: the tool runs as soon as the person approves it, and the approval card then shows Executed, Failed or Declined > - After the tool runs, the server wakes the agent with the recorded result. When another turn is active, that wake waits behind it > - The composer queue lists waiting interaction receipts as messages. The result wake carries the approval's interaction id and status, so the queue shows "Accepted: Approve tool action" with an Interrupt control, after the tool already ran > - People read this row as an approval that is still not done, and Interrupt stops their current turn only to deliver a tool result > - This pull request keeps tool action outcome wakes out of the composer queue and out of its steer, interrupt and discard actions > - The benefit is a steering pane that shows only messages the person can act on, while the agent still receives the tool result after the current turn ## Linked Issues or Issue Description **What happened?** After a person approves a governed tool action, the tool runs and its card shows Succeeded. When the person then sends a new message, the composer queue shows a row "Accepted: Approve tool action" with Interrupt and Discard controls. The row stays until the active turn ends. **What did you expect?** The steering pane does not show the tool result as a queued message. The approval card already shows the outcome. The agent receives the result after the current turn. **Steps to reproduce** 1. Let an agent request approval for a governed tool action (`request_confirmation` with `payload.toolAction`). 2. Approve it. The tool runs and the card shows Succeeded. 3. Send a new message so a new turn starts before the result wake runs. 4. See "Accepted: Approve tool action" in the composer queue. ## What Changed - Add `isToolActionOutcomeReceipt` in `server/src/services/queued-interaction-response.ts`. It identifies an interaction receipt wake that carries a tool action (`toolAction` identity or `toolActionRequestIds`). - `findQueuedCommentWake` in `server/src/routes/issues.ts` skips these wakes, so `GET /issues/:id/queued-comments` does not list them. The payload projection adds only the tool action's `actionRequestId`, `toolName` and `toolActionRequestIds`, never its result text. - The locked by-id queue lookup (steer, interrupt, discard) rejects these wakes with `queued_comment_not_pending`, so a stale client cannot interrupt a turn for a tool result. - Delivery, stranded-queue recovery and the handback hold (`readQueuedInteractionResponse`, `hasQueuedInteractionResponse`) do not change. The agent still receives the result. - Add route tests for accepted and declined tool action outcomes. They check that the queue is empty, the wake stays `deferred_issue_execution`, and the issue still has owed interaction input. ## Verification - `cd server && npx vitest run src/__tests__/issue-queued-comments-routes.test.ts src/services/issue-queued-comment-queue.test.ts`: 137 of 137 tests pass. The new tests fail without the fix (the queue lists the receipt). - On a heavily loaded machine (load average about 25), the embedded Postgres `afterAll` teardown timed out once at 30 s after all tests passed. This is not related to the change. - `cd server && npx tsc --noEmit -p .`: clean. ## Risks - Low risk. The change only filters the user-facing queue view and its by-id actions. - A tool action outcome wake no longer appears as an Interrupt target. Before, Interrupt could deliver a tool result into a new turn early. Now the result arrives when the current turn ends, as for other system deliveries. - #13160 refactors the same steering path. One of the two PRs may need a small rebase. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits, browser inspection of staging). ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
153 lines
8.4 KiB
TypeScript
153 lines
8.4 KiB
TypeScript
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
import { agentWakeupRequests, issueThreadInteractions, type Db } from "@paperclipai/db";
|
|
import type { IssueComment, IssueQueuedCommentEntry } from "@paperclipai/shared";
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
/** Interaction receipts keep their typed payload; they never become editable comments. */
|
|
export function queuedInteractionId(payload: unknown): string | null {
|
|
const p = record(payload);
|
|
return p.mutation === "interaction" && typeof p.interactionId === "string"
|
|
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p.interactionId)
|
|
&& ["accepted", "answered", "rejected"].includes(String(p.interactionStatus))
|
|
&& p.externalChatContinuation !== true
|
|
? p.interactionId : null;
|
|
}
|
|
|
|
/**
|
|
* A governed tool action resolves as approve = run: by the time a receipt for
|
|
* it waits behind another turn, the tool already ran or was declined, and the
|
|
* approval card shows that outcome. The receipt only hands the recorded result
|
|
* to the agent. It still counts as owed input for delivery and recovery, but it
|
|
* is not a message the person can send, steer or interrupt with, so the
|
|
* composer queue never lists it. The saved interaction decides this, because a
|
|
* recovered wake does not carry the tool action fields of the original one.
|
|
*/
|
|
export function isToolActionConfirmation(interaction: { kind: string; payload?: unknown }): boolean {
|
|
const toolAction = record(record(interaction.payload).toolAction);
|
|
return interaction.kind === "request_confirmation" && Object.keys(toolAction).length > 0;
|
|
}
|
|
|
|
/**
|
|
* One readable line for a queued response. The steering body also carries the
|
|
* typed JSON receipt for the agent; people only ever see this line.
|
|
*/
|
|
export function queuedInteractionSummary(interaction: {
|
|
kind: string; status: string; title?: string | null; payload?: unknown;
|
|
}): string {
|
|
const p = record(interaction.payload);
|
|
if (interaction.kind === "connection_intent") {
|
|
const service = typeof p.serviceName === "string" && p.serviceName.trim() ? p.serviceName.trim() : null;
|
|
const subject = service ? `${service} connection` : interaction.title ?? "Connection request";
|
|
return interaction.status === "rejected" ? `Declined: ${subject}` : `Connected: ${subject}`;
|
|
}
|
|
const label = interaction.status === "accepted" ? "Accepted" : interaction.status === "rejected" ? "Rejected" : "Answered";
|
|
const subject = interaction.title ?? (typeof p.prompt === "string" ? p.prompt : null) ?? "Questions";
|
|
return `${label}: ${subject}`;
|
|
}
|
|
|
|
/**
|
|
* A run that started with this exact response in its context already received
|
|
* it. The run must carry the resolved status: the card-creation wake carries
|
|
* the same interaction id while the card is still pending, so it never counts.
|
|
* Any other wake that still carries the same receipt (for example a recovery
|
|
* continuation that raced the response's own wake) is redundant: it is not
|
|
* pending input, so it must not hold the composer queue or the issue.
|
|
* A cancelled or failed start does not count; that response is still owed.
|
|
*/
|
|
// Table-qualified on purpose: inside a select list Drizzle renders columns
|
|
// unqualified, which would bind them to the subquery's own run row.
|
|
function deliveredInteractionRunExists(companyId: string, issueId: string) {
|
|
return sql<boolean>`EXISTS (SELECT 1 FROM heartbeat_runs delivered_run
|
|
WHERE delivered_run.company_id = ${companyId}
|
|
AND delivered_run.context_snapshot->>'issueId' = ${issueId}
|
|
AND delivered_run.context_snapshot->>'interactionId' = issue_thread_interactions.id::text
|
|
AND delivered_run.context_snapshot->>'interactionStatus' = issue_thread_interactions.status
|
|
AND delivered_run.started_at IS NOT NULL
|
|
AND delivered_run.status IN ('running', 'succeeded'))`;
|
|
}
|
|
|
|
/**
|
|
* The explicit steering message for a resolved interaction. It preserves the
|
|
* question labels, selected options, custom answers, approval target and exact
|
|
* revision.
|
|
*/
|
|
export function queuedInteractionSteeringBody(interaction: {
|
|
id: string; kind: string; status: string; title?: string | null; payload?: unknown; result?: unknown;
|
|
}): string {
|
|
return `${queuedInteractionSummary(interaction)}\n\n${JSON.stringify({
|
|
interactionId: interaction.id, kind: interaction.kind, status: interaction.status,
|
|
payload: interaction.payload, result: record(interaction.result),
|
|
}, null, 2)}`;
|
|
}
|
|
|
|
export type QueuedInteractionResponse = NonNullable<Awaited<ReturnType<typeof readQueuedInteractionResponse>>>;
|
|
|
|
/** A stored human answer can reach its assignee while dependency work stays blocked. */
|
|
export async function isVerifiedDependencyInteractionResponse(db: Pick<Db, "select">, input: {
|
|
companyId: string; issueId: string; agentId: string; context: unknown;
|
|
}): Promise<boolean> {
|
|
const id = queuedInteractionId(input.context);
|
|
if (!id) return false;
|
|
const context = record(input.context);
|
|
const [interaction] = await db.select({ status: issueThreadInteractions.status, kind: issueThreadInteractions.kind,
|
|
resolvedByUserId: issueThreadInteractions.resolvedByUserId, resolvedAt: issueThreadInteractions.resolvedAt })
|
|
.from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.id, id), eq(issueThreadInteractions.companyId, input.companyId),
|
|
eq(issueThreadInteractions.issueId, input.issueId), eq(issueThreadInteractions.createdByAgentId, input.agentId),
|
|
)).limit(1);
|
|
return Boolean(interaction?.resolvedByUserId && interaction.resolvedAt
|
|
&& interaction.status === context.interactionStatus
|
|
&& (context.interactionKind === undefined || interaction.kind === context.interactionKind));
|
|
}
|
|
|
|
export async function readQueuedInteractionResponse(db: Db, companyId: string, issueId: string, payload: unknown) {
|
|
const id = queuedInteractionId(payload);
|
|
if (!id) return null;
|
|
const [row] = await db.select({
|
|
interaction: issueThreadInteractions,
|
|
delivered: deliveredInteractionRunExists(companyId, issueId),
|
|
}).from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.id, id), eq(issueThreadInteractions.companyId, companyId),
|
|
eq(issueThreadInteractions.issueId, issueId),
|
|
inArray(issueThreadInteractions.status, ["accepted", "answered", "rejected"]),
|
|
));
|
|
const interaction = row?.interaction;
|
|
if (!interaction || interaction.status !== record(payload).interactionStatus || row.delivered) return null;
|
|
const heading = queuedInteractionSummary(interaction);
|
|
const body = queuedInteractionSteeringBody(interaction);
|
|
const comment: IssueComment = {
|
|
id: interaction.id, companyId, issueId, authorType: interaction.resolvedByUserId ? "user" : "agent",
|
|
authorUserId: interaction.resolvedByUserId, authorAgentId: interaction.resolvedByAgentId,
|
|
body, presentation: null, metadata: null,
|
|
createdAt: interaction.resolvedAt ?? interaction.updatedAt, updatedAt: interaction.updatedAt,
|
|
};
|
|
const source: NonNullable<IssueQueuedCommentEntry["source"]> = {
|
|
kind: "interaction", interactionId: interaction.id, interactionKind: interaction.kind,
|
|
requiresFreshSession: record(record(payload)._paperclipWakeContext).forceFreshSession === true,
|
|
summary: heading,
|
|
};
|
|
return { comment, source, toolActionOutcome: isToolActionConfirmation(interaction) };
|
|
}
|
|
|
|
/** A saved answer is a live continuation, even though its card is no longer pending. */
|
|
export async function hasQueuedInteractionResponse(db: Db, companyId: string, issueId: string, agentId?: string | null) {
|
|
if (!agentId) return false;
|
|
const [row] = await db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests)
|
|
.innerJoin(issueThreadInteractions, and(
|
|
eq(issueThreadInteractions.companyId, agentWakeupRequests.companyId),
|
|
sql`${issueThreadInteractions.id}::text = ${agentWakeupRequests.payload}->>'interactionId'`,
|
|
eq(issueThreadInteractions.issueId, issueId),
|
|
inArray(issueThreadInteractions.status, ["accepted", "answered", "rejected"]),
|
|
)).where(and(
|
|
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId),
|
|
inArray(agentWakeupRequests.status, ["deferred_issue_execution", "queued"]),
|
|
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
|
|
sql`${agentWakeupRequests.payload}->>'mutation' = 'interaction'`,
|
|
sql`NOT ${deliveredInteractionRunExists(companyId, issueId)}`,
|
|
)).limit(1);
|
|
return Boolean(row);
|
|
}
|