fix(chat): post a conversation turn's answer while an approval holds the task (#20179)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People talk to agents in chat. The agent's final message becomes the
chat reply.
> - A chat turn can end while the task stays held. For example, the
agent resubmits a hire approval and the approval is pending.
> - The finalizer keeps the task in review for the approval
(`governed_response_waiting`). That decision is not
`conversation_turn_finished`.
> - Run completion then treats the yielded turn as a control-plane wait.
It records `presentationDecision: none` and posts no comment.
> - The person never sees the agent's answer. The chat shows nothing
after the request.
> - This pull request counts a committed conversation turn as answered
unless its own pending question or confirmation card presents it.
> - The benefit is that the person always gets the reply, whatever state
the task keeps.

## Linked Issues or Issue Description

Refs #19971 (post the full final reply). That fix covers the reply text.
This fix covers replies that were never posted.

**What happened**

In a chat, the person asked the CEO agent to change a proposed hire to a
cheaper model and send it back for approval. The agent resubmitted the
approval correctly. It called `paperclip_finish`, and Paperclip accepted
the report with "task is still waiting for approval". The run succeeded
and the task moved to `in_review`. The run event
`run.presentation.resolved` recorded `commentAction: none` (reason
`yielded_control_plane_wait` on master). The agent's final message never
reached the chat.

**Expected behavior**

A chat turn's accepted final answer is always posted to the
conversation. An approval, an execution stage or a dependency blocker
can hold the task. It must not hide the reply. Only a question or
confirmation card that the turn itself left pending presents the turn
instead of its prose.

**Steps to reproduce**

1. Open an Agent Chat with an agent. Link a hire approval in
`revision_requested` to the chat task.
2. Ask the agent to change the hire and resubmit it.
3. The agent resubmits the approval and ends the turn with a yielded
result and a response wake.
4. See that the chat shows no reply. The run is `succeeded` and its
presentation decision is `none`.

## What Changed

- `server/src/services/native-runtime/native-governance.ts`: move the
pending-card query of `pendingNativeGovernance()` into
`pendingNativeInteraction()`, with no change in behavior. Add
`conversationTurnAnswered()`. It returns false only when a pending
thread interaction (the same predicate as status arbitration) gates the
turn. It checks cards on their own, so a pending execution stage does
not hide a card.
- `server/src/services/heartbeat/run-completion.ts`: a successful
conversation run whose status decision committed now passes
`conversationTurnFinished: true` to the presentation resolver, unless
`conversationTurnAnswered()` is false. The existing
`conversation_turn_finished` path is unchanged.
- `server/src/services/heartbeat/run-completion.test.ts`: add a
regression table with a real database. A pending approval, a
revision-requested approval, a pending execution stage and a dependency
blocker now post the answer. A finished turn still posts. The turn's own
question card still owns the response, also when an execution stage is
pending.
- `doc/execution-semantics.md`: state the rule.

Other finish outcomes I checked:

- `done` or `needs_review` with a gate (`governed_gate_pending`): the
result is not yielded, so the reply was already posted.
- `blocked` (`paperclip_block`, waiting on a person or another task):
not yielded, so the reply was already posted.
- Yielded with a dependency blocker
(`durable_dependency_blocker_bound`), or with
`record_finalization_error` (`prior_status_preserved_no_live_path`,
`monitor_wait_authority_lost`): the reply was also dropped. This fix
covers them, because the rule does not depend on the reason code.

## Verification

- `cd server && npx vitest run
src/services/heartbeat/run-completion.test.ts`: 118 passed. Before the
fix, 4 new cases failed (both approval states, execution stage,
dependency blocker). The finished-turn and question-card cases passed
both before and after.
- `cd server && npx vitest run
src/__tests__/heartbeat-run-summary.test.ts
src/services/heartbeat/run-comment-selection.test.ts
src/__tests__/slack-conversation-lifecycle.test.ts
src/services/native-runtime/agent-chat-response-recovery.test.ts
src/__tests__/chat-run-milestone-governed-work.test.ts
src/__tests__/agent-conversations.test.ts`: all passed.
- `tsc --noEmit -p server`: no errors in the changed files. Unrelated
errors in this worktree come from an unbuilt local Runner package.
- An end-to-end chat eval run on the case branch is in progress. I will
add its result here.

## Risks

- Low risk. The change only affects successful Agent Chat runs with a
committed status decision. Ordinary tasks and external chat (Slack,
GitHub) keep their own paths. External chat returns before this check.
- A turn that yields while an approval is pending now posts its prose.
The approval stays pending and the task status does not change.
- One extra small read (the task row and pending gates) runs after a
conversation turn that is not `conversation_turn_finished`. It runs
after the provider turn ends, so it does not add time before the first
token.
- Previous-release state: no persisted shape changes. The fix reads
`finalizationPhase` and `finalizationReasonCode`, which the previous
release already writes. A run in flight at the deploy completes on the
new code and posts its reply. A run that completed before the deploy
keeps its recorded decision; nothing re-presents it.
- Known gap, unchanged: background presentation recovery for
conversations still requires `conversation_turn_finished` with a `done`
result. If the server restarts between the status commit and the live
presentation, a held yielded turn is not re-posted. That is a separate
follow-up.

> 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-08 23:17:36 -06:00
1 parent c5c587f162
commit 95bf90bd2c
4 files changed
+123 -19

No files matched your search

@@ -24,6 +24,32 @@ export async function lockNativeGovernanceSources(input: { db: Db; companyId: st
eq(approvals.companyId, input.companyId))).for("share", { noWait: true });
}
/** The pending thread interaction (question or confirmation card) that gates this run, if any. */
async function pendingNativeInteraction(input: {
db: Db;
companyId: string;
issueId: string;
runId: string;
ignoredInteractionIds?: string[];
}): 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)));
return input.db
.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
eq(issueThreadInteractions.status, "pending"),
...(input.ignoredInteractionIds?.length ? [notInArray(issueThreadInteractions.id, input.ignoredInteractionIds)] : []),
activeIssueInteractionCondition({ runId: input.runId, conversationMode: isConversation(issue) }),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
}
export async function pendingNativeGovernance(input: {
db: Db;
companyId: string;
@@ -37,23 +63,8 @@ export async function pendingNativeGovernance(input: {
if (record(input.executionState).status === "pending") {
return { kind: "execution_stage", id: input.runId };
}
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)));
const [pendingInteraction, pendingApproval] = await Promise.all([
input.db
.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
eq(issueThreadInteractions.status, "pending"),
...(input.ignoredInteractionIds?.length ? [notInArray(issueThreadInteractions.id, input.ignoredInteractionIds)] : []),
activeIssueInteractionCondition({ runId: input.runId, conversationMode: isConversation(issue) }),
),
)
.limit(1)
.then((rows) => rows[0] ?? null),
pendingNativeInteraction(input),
input.db
.select({ id: approvals.id })
.from(issueApprovals)
@@ -75,3 +86,20 @@ export async function pendingNativeGovernance(input: {
return null;
}
/**
* 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.
*/
export async function conversationTurnAnswered(input: {
db: Db;
companyId: string;
issueId: string;
runId: string;
}): Promise<boolean> {
// 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));
}