Files
PaperClipAI/server/src/services/queued-interaction-response.ts
T
DottaandClaude Opus 5.5 6dd8611a2f fix(queue): keep tool action outcomes out of the steering queue (#20582)
## 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>
2026-11-11 07:39:37 -06:00

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);
}