From 8525bb534c211ba39d0e29e2b4c1a6fb4f4c4d61 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:22:01 -0600 Subject: [PATCH] fix(chat): post a turn's answer when a card it did not create is pending (#20220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People talk to agents in Agent Chat. The agent's final message becomes the chat reply. > - A chat thread can hold a pending card that the board addressed to another person, for example a human-only confirmation for Riley. > - When Alex asks the agent to act on that card, the agent correctly gets `403 interaction_human_only` and writes a final answer that explains the denial. > - The shared answered rule (`conversationTurnAnswered()`, from #20179 and #20204) counted any pending human-only card in the thread as the turn's own response. So the answer was suppressed, and the chat stayed `active` with no reply. > - This pull request limits that rule to cards that the turn's own run created. Other cards still hold the task status. > - The benefit is that the person always gets the turn's answer, unless the turn itself asked a question with a card. Live completion and restart recovery stay consistent. ## Linked Issues or Issue Description Refs #20179, #20204 (the shared answered rule this PR corrects). **What happened** In an Agent Chat, the board created a pending `request_confirmation` card with `resolverPolicy: human_only`, addressed to another user (Riley). The card has no source run. Alex wrote "I approve Riley's request." The agent tried to accept the card and got `403 interaction_human_only`. It then wrote a correct final answer: the card needs Riley. The run succeeded with a `yielded` result and a `response_wake` continuation. Run completion recorded `presentationDecision: none`. No comment was created. The conversation stayed `active`, so Alex never got a reply. Cause: `conversationTurnAnswered()` reused the status-gate query `pendingNativeInteraction()`. In conversation mode, that query excludes another run's card only when it is an ordinary question or an `anyone` confirmation. A human-only card addressed to someone else is kept. The rule then treated that card as the card that "presents the turn", although this run did not create it. **Expected behavior** A successful chat turn posts its final answer exactly once, and the conversation moves to `waiting`. Only a pending question or confirmation card that the turn's own run created presents the turn in place of its prose. A card from an earlier turn, or one the board addressed to another person, can hold the task status, but it does not suppress the answer. **Steps to reproduce** 1. Open an Agent Chat as Alex. 2. As the board, create a pending `request_confirmation` card in the chat with `resolverPolicy: human_only` and `addresseeUserId` set to another person. 3. Send "I approve Riley's request." 4. Let the agent try to accept the card, get `403 interaction_human_only`, and end its turn with a final answer. 5. See that no reply appears, `presentationDecision.commentAction` is `none`, and `conversationState` stays `active`. ## What Changed - `server/src/services/native-runtime/native-governance.ts`: `conversationTurnAnswered()` now checks only pending cards whose `sourceRunId` is the turn's run. `pendingNativeInteraction()` gets an optional `createdByRun` filter for this. The status gate (`pendingNativeGovernance()`) does not change, so another person's card still holds the task. - Both callers get the fix through the one shared rule `conversationTurnPresentsAnswer()`: live run completion (`run-completion.ts`) and background presentation recovery (`recoverCommittedNativeChatResponse`). - `server/src/services/heartbeat/run-completion.test.ts`: new live cases "someone else's confirmation card" (board-created, human-only, addressed to another user, no source run) and "earlier turn's question card" (created by an earlier run of the same agent). Each case now also checks that the conversation moves to `waiting`. - `server/src/services/native-runtime/agent-chat-response-recovery.test.ts`: new restart case for the board-created card, plus a `waiting` check after recovery. - `doc/execution-semantics.md`: state that only the turn's own card presents the turn. ## Verification - Thesis: the reply is suppressed because the answered rule counts any pending human-only card in the thread, not only this run's card. If this is true, a turn with only a board-created card addressed to someone else posts no comment on master, and restricting the rule to `sourceRunId = runId` posts exactly one comment and settles the conversation to `waiting`, with the own-card cases unchanged. - Failing first: without the fix, the two new live cases failed (`expected [] to deeply equal [answer]`). The new recovery case also failed (no comment). - After the fix: - `cd server && npx vitest run src/services/heartbeat/run-completion.test.ts -t "posts a waiting conversation turn"`: 9 passed (includes the two own-card cases, which still post nothing). - `cd server && npx vitest run src/services/native-runtime/agent-chat-response-recovery.test.ts`: 62 passed. - Both suites use a real embedded PostgreSQL database. - Turn start: no change. The rule runs only after the provider turn ends. ## Risks - Low. Only the presentation rule changes. The status gate, the conversation settle rule (it already required `sourceRunId = run.id`) and the external-chat path do not change. - Behavior shift: a turn now posts its answer while another run's or the board's card is pending. Before, the answer was lost. A card that the turn itself created still presents the turn, as before. - Previous-release state: no persisted shape change. A run that the previous release already recorded as `none` keeps that decision. Recovery does not re-present it. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use (shell, file edits, test runs) in Claude Code. ## 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 --- doc/execution-semantics.md | 6 +++-- .../services/heartbeat/run-completion.test.ts | 25 ++++++++++++++++++- .../agent-chat-response-recovery.test.ts | 13 ++++++++-- .../native-runtime/native-governance.ts | 12 ++++++--- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 172130221c4..2adc1c2198d 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2267,8 +2267,10 @@ In an Agent Chat conversation, a successful turn whose status decision committed posts its final answer to the conversation even when the task stays held: a pending or revision-requested approval, a pending execution stage, or a dependency blocker. A held task does not present the turn. Only a pending -thread interaction (a question or confirmation card) presents the turn in place -of its prose. Background presentation recovery applies the same rule, so a +thread interaction (a question or confirmation card) that the turn's own run +created presents the turn in place of its prose. A card from an earlier turn, +or one the board addressed to another person, can hold the task but does not +present the turn, so its answer is still posted. Background presentation recovery applies the same rule, so a restart between the status commit and the live presentation posts the held turn's answer once, or records that its card presented the turn. diff --git a/server/src/services/heartbeat/run-completion.test.ts b/server/src/services/heartbeat/run-completion.test.ts index 42c34facc34..10f480c9760 100644 --- a/server/src/services/heartbeat/run-completion.test.ts +++ b/server/src/services/heartbeat/run-completion.test.ts @@ -16,7 +16,7 @@ import { createHash, randomUUID } from "node:crypto"; import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js"; import { createNativeRunLogCoverageVerifier, formatNativeRunLogEvent } from "../native-runtime/native-run-log-export.js"; import type { Db } from "@paperclipai/db"; -import { activityLog, agents, agentWakeupRequests, approvals, issueApprovals, budgetReservations, caseHandoffs, cases, companies, completionContracts, costEvents, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, heartbeatRunEvents, issueComments, issueQuestionResponseDeliveries, issueRecoveryActions, issues, nativeRunFinalizations } from "@paperclipai/db"; +import { activityLog, agents, agentWakeupRequests, approvals, issueApprovals, budgetReservations, caseHandoffs, cases, companies, completionContracts, costEvents, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, heartbeatRunEvents, issueComments, issueQuestionResponseDeliveries, issueRecoveryActions, issues, issueThreadInteractions, nativeRunFinalizations } from "@paperclipai/db"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; @@ -481,6 +481,10 @@ describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)( { hold: "finished turn", reasonCode: "conversation_turn_finished", replies: true }, { hold: "own question card", reasonCode: "governed_response_waiting", replies: false }, { hold: "execution stage and own question card", reasonCode: "governed_response_waiting", replies: false }, + // A card the board addressed to someone else holds the task, but this run + // did not create it, so it cannot be the turn's response. + { hold: "someone else's confirmation card", reasonCode: "governed_response_waiting", replies: true }, + { hold: "earlier turn's question card", reasonCode: "governed_response_waiting", replies: true }, ] as const)("posts a waiting conversation turn's accepted answer: $hold", async ({ hold, reasonCode, replies }) => { const f = await fixture({ runtimeMode: "native" }, `CNV${randomUUID().slice(0, 6)}`); const [issue] = await db.insert(issues).values({ companyId: f.run.companyId, title: "Chat with CEO", @@ -501,6 +505,22 @@ describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)( selectionMode: "single", required: true, options: [{ id: "flash", label: "Flash" }] }] } }, { agentId: f.agent.id, runId: f.run.id }); } + if (hold === "someone else's confirmation card") { + // Saved as the board's create route saves it: no source run. + await db.insert(issueThreadInteractions).values({ companyId: f.run.companyId, issueId: issue.id, + kind: "request_confirmation", status: "pending", continuationPolicy: "none", title: "Riley's website proposal", + requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only", resolverPolicyProvenance: "explicit", + addresseeUserId: "riley", createdByUserId: "local-board", + payload: { version: 1, prompt: "Riley: approve creating the website implementation task?" } }); + } + if (hold === "earlier turn's question card") { + const [earlier] = await db.insert(heartbeatRuns).values({ companyId: f.run.companyId, agentId: f.agent.id, + status: "succeeded", runtimeMode: "native", responsibleUserId: "local-board" }).returning(); + await issueThreadInteractionService(db).create(issue, { kind: "request_confirmation", sourceRunId: earlier.id, + resolverPolicy: "human_only", continuationPolicy: "wake_assignee", + payload: { version: 1, prompt: "Create the website implementation task?" } }, + { agentId: f.agent.id, runId: earlier.id }); + } const answer = "Resubmitted the hire with the cheaper model. [Review the pending approval](/CNV/approvals/a1)."; f.input.issueId = issue.id; f.input.issueRef = issue; @@ -528,6 +548,9 @@ describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)( expect((await read(f.run.id)).resultJson).toMatchObject({ presentationDecision: replies ? { chosenSource: "final_agent_message", commentAction: "create", commentId: posted[0]?.id } : { chosenSource: "none", commentAction: "none" } }); + // The posted answer (or the run's own card) is the turn's response: the + // conversation waits for the person. + expect((await db.select().from(issues).where(eq(issues.id, issue.id)))[0].conversationState).toBe("waiting"); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); it("settles the accepted native exact rate-card price without replacing it with zero", async () => { diff --git a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts index 4d048122992..2b569acced4 100644 --- a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts +++ b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts @@ -716,7 +716,8 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => // person. A restart between the status commit and the live presentation // must not lose that answer. The run is saved exactly as the previous // release's finalizer saves it, with no presentation recorded yet. - type Hold = "pending approval" | "approval sent back for revision" | "execution stage" | "own question card"; + type Hold = "pending approval" | "approval sent back for revision" | "execution stage" | "own question card" + | "someone else's confirmation card"; async function heldTurn(hold: Hold, disposition: "yielded" | "done" = "yielded") { return fixture(true, { disposition, beforeFinalize: async ({ companyId, issueId, runId, agentId }) => { if (hold.includes("approval")) { @@ -730,11 +731,18 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => sourceRunId: runId, createdByAgentId: agentId, payload: { version: 1, questions: [{ id: "model", prompt: "Which model?", selectionMode: "single", required: true, options: [{ id: "flash", label: "Flash" }] }] } }); } + // The board addressed this card to another person; the run did not create it. + if (hold === "someone else's confirmation card") { + await db.insert(issueThreadInteractions).values({ companyId, issueId, kind: "request_confirmation", status: "pending", + continuationPolicy: "none", title: "Riley's website proposal", requestedResolverPolicy: "human_only", + effectiveResolverPolicy: "human_only", resolverPolicyProvenance: "explicit", addresseeUserId: "riley", + createdByUserId: "local-board", payload: { version: 1, prompt: "Riley: approve creating the website implementation task?" } }); + } } }); } it.each([ ["pending approval", "yielded"], ["approval sent back for revision", "yielded"], ["execution stage", "yielded"], - ["pending approval", "done"], + ["pending approval", "done"], ["someone else's confirmation card", "yielded"], ] as const)( "recovers a held conversation turn's answer exactly once after a restart: %s (%s)", async (hold, disposition) => { const f = await heldTurn(hold, disposition); @@ -749,6 +757,7 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => expect(await comments(f.runId)).toEqual([expect.objectContaining({ body: f.reply, authorAgentId: f.agentId, createdByRunId: f.runId })]); expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].resultJson).toMatchObject({ presentationDecision: { chosenSource: "final_agent_message", commentAction: "create", commentId: expect.any(String) } }); + expect(await issue(f.issueId)).toMatchObject({ conversationState: "waiting" }); expect(await work.load(f.input)).toEqual([]); await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false); expect(await comments(f.runId)).toHaveLength(1); diff --git a/server/src/services/native-runtime/native-governance.ts b/server/src/services/native-runtime/native-governance.ts index 9be92563bf6..5009a2730ec 100644 --- a/server/src/services/native-runtime/native-governance.ts +++ b/server/src/services/native-runtime/native-governance.ts @@ -31,6 +31,8 @@ async function pendingNativeInteraction(input: { issueId: string; runId: string; ignoredInteractionIds?: string[]; + /** Only a card this run created; any other card can hold the task but not present the turn. */ + createdByRun?: boolean; }): Promise<{ id: string } | null> { const [issue] = await input.db.select({ conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId }) .from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))); @@ -43,6 +45,7 @@ async function pendingNativeInteraction(input: { eq(issueThreadInteractions.issueId, input.issueId), eq(issueThreadInteractions.status, "pending"), ...(input.ignoredInteractionIds?.length ? [notInArray(issueThreadInteractions.id, input.ignoredInteractionIds)] : []), + ...(input.createdByRun ? [eq(issueThreadInteractions.sourceRunId, input.runId)] : []), activeIssueInteractionCondition({ runId: input.runId, conversationMode: isConversation(issue) }), ), ) @@ -89,9 +92,10 @@ export async function pendingNativeGovernance(input: { /** * A committed conversation turn has answered the person, whatever status its - * task keeps. An approval or an execution stage can hold the task, but neither - * presents that answer in the conversation. Only a pending thread interaction - * (a question or confirmation card) is the turn's visible response. + * task keeps. An approval, an execution stage or a card the run did not create + * (an earlier turn's, or one the board addressed to someone else) can hold the + * task, but none presents that answer in the conversation. Only a pending + * question or confirmation card this run created is the turn's visible response. */ export async function conversationTurnAnswered(input: { db: Db; @@ -101,7 +105,7 @@ export async function conversationTurnAnswered(input: { }): Promise { // Check cards on their own: an execution stage outranks a card as the // status gate, but it does not present the turn. - return !(await pendingNativeInteraction(input)); + return !(await pendingNativeInteraction({ ...input, createdByRun: true })); } /**