Files
PaperClipAI/server
DottaandClaude Opus 5.5 8a6b082274 fix(api): send call_api mutations once when responseText asks only for the first window (#20344)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native agents use the `call_api` fallback tool for API operations
that have no dedicated tool.
> - `responseText` is an optional `call_api` argument. It returns a
bounded text window of a GET response.
> - The server rejects every `responseText` on a mutation, also a
first-window request that cannot repeat the mutation.
> - A native agent attached `responseText` to a company rename PATCH. It
got the same 400 on each retry, and no call succeeded.
> - The same call also had no `body`. A PATCH without a body changes
nothing, but the route returns 200. With the first rejection removed,
the agent could read that 200 as a completed rename.
> - This pull request sends a mutation once when `responseText` asks
only for the first window, and it rejects a PATCH without a body before
dispatch.
> - The benefit is that a valid mutation goes through on the first call,
a later-page request still cannot repeat a mutation, and an empty PATCH
cannot look like a successful update.

## Linked Issues or Issue Description

Refs #18105. That open pull request keeps the 400 for all mutation
`responseText` values and corrects its message. This pull request
changes the same check. It accepts a first-window request and keeps a
corrected 400 for a later page. Whichever pull request merges second
must resolve this line.

**What happened**

A chat agent (OpenCode, `deepseek-v4.1-flash`) tried to rename its
company with `call_api`. We recovered the exact arguments from the
payload digest in the run events: `operationId: "PATCH
/api/companies/{companyId}"`, `pathParams.companyId`, `query: {}` and
`responseText: { limitBytes: 8192 }`. There was no `body`. The OpenCode
MCP bridge validates the arguments with Ajv without `useDefaults`, and
`8192` is not a default in any schema. Thus the model, not the bridge,
sent `responseText`. The server returned "responseText requires a GET
operation". The agent sent the identical call 10 more times and one
identical branding PATCH. No call succeeded. A second chat case in the
same cohort sent `responseText` on `POST /api/issues/{id}/inbox-archive`
three times before it recovered.

**Expected behavior**

`responseText` with no offset or offset 0 changes only how the response
is shown. The mutation must run once and return its response on the
normal inline or saved-artifact path. A later page (`offsetBytes > 0`)
must still fail before dispatch, because it would repeat the mutation. A
PATCH without a body must fail before dispatch with a message that names
`body`, the allowed fields and the received argument keys.

**Steps to reproduce**

1. Call `executeRunnerApi` with `PATCH /api/companies/{companyId}`, a
body, and `responseText: { limitBytes: 8192 }`.
2. Before this change: a 400, and no HTTP request. After: one PATCH with
the body, and the normal response.
3. Call it with no body. Before: the request is sent and the route
returns 200 with no change. After: a 400 that names `body`, and no HTTP
request.

## What Changed

- `validateRunnerApiCall`: for a non-GET operation, drop a first-window
`responseText` (no offset or offset 0) and continue. A later page still
returns 400 with "This request was not sent" and the reason.
- New `assertRunnerApiDispatchable`: reject a new PATCH with a required
request body when `body` and `files` are both absent. The message lists
up to 12 allowed fields and the received argument keys. `call_api` runs
it inside its receipt reservation, after saved and inherited receipts
are read and before it writes a pending receipt, so a rejected call
leaves no receipt. `executeRunnerApi` also runs it for direct callers.
Recovery and receipt replay use only `validateRunnerApiCall`, so a
body-less PATCH that an older server completed still returns its saved
result.
- Tests: first-window `responseText` on a PATCH dispatches once with the
body (three window shapes). A later page is rejected with no dispatch. A
body-less PATCH is rejected with no dispatch, and `body: {}` is still
accepted. The catalog-wide dispatch tests now send `body: {}` for PATCH
operations with a required body.

## Verification

- Red: the 5 new or changed cases failed on `origin/master`
(`runner-api.test.ts`).
- Green: `npx vitest run src/services/native-runtime/runner-api.test.ts
src/services/native-runtime/runner-tool-results.test.ts` passed (2,087
tests). `tests/runner-e2e/api-response-reading.test.ts` passed (14
tests).
- `tsc --noEmit -p server/tsconfig.json`: no new errors. The error count
is the same before and after this change; the remaining errors come from
packages that are not built in this worktree.
- Upgrade safety and receipts: a new integration test seeds a completed
receipt for a body-less PATCH. `recover()` and a same-callId `execute()`
return the saved result. A new body-less PATCH is rejected twice with
the same error, writes no receipt and sends no HTTP request, and the
corrected PATCH with that callId then succeeds. The test failed with the
rule in `validateRunnerApiCall`, and again with the rule after the
reservation; it passes now. `runner-api.integration.test.ts`,
`runner-api.test.ts` and `native-case-tools.integration.test.ts` pass
(2,164 passed, 2 skipped).
- One paid chat eval run (GitHub Actions run 42680925312) with the first
commit cherry-picked onto the eval branch: `companies.rename-company`
passed. The agent sent one correct PATCH with a body, attributed to the
agent. That run did not send `responseText`, so it does not exercise the
changed path; the unit tests cover it.

## Risks

- Behavior change: a mutation with a first-window `responseText` now
runs. Before, it failed. The response comes back on the normal path
(inline up to 24 KiB, else a saved artifact), not as a text window.
- Behavior change: a caller that sent a PATCH with no body to a route
with a required body now gets a 400. Such a call changed nothing before.
POST, PUT and DELETE are not changed, because some of them have useful
empty bodies.
- No route, permission or company check changes.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code
execution 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 14:03:08 -06:00
..