Files
PaperClipAI/server
DottaandClaude Opus 5.5 8525bb534c fix(chat): post a turn's answer when a card it did not create is pending (#20220)
## 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 <noreply@anthropic.com>
2026-11-09 03:22:01 -06:00
..