fix(chat): send writes with an empty responseText and point card declines to resolve-from-comment

#21035 refused any write whose call_api input had a responseText key, even
responseText: {} beside a valid body, which looped a DeepSeek chat until its
window closed. An empty or default responseText is now dropped and the write is
sent once; a responseText that sets a window is still refused.

The cancel and withdraw summaries from #21035 named withdraw as the agent route,
so an agent told "do not publish it" left a person-created card pending. The
summaries and chat directive now say withdraw is only for the agent's own
obsolete card and a person's decline is recorded with resolve-from-comment when
the card's resolver policy allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-12 01:46:01 -06:00
1 parent 3c058c37e6
commit 3f20fb2bcc
8 files changed
+67 -20

No files matched your search

+7 -4
View File
@@ -149,10 +149,13 @@ The result contains `data` as text (including JSON), plus `responseText` with
until it is null. Windows end at UTF-8 boundaries. The limit defaults to 24 KiB
and accepts 4–24,576 bytes. The server rejects binary content, invalid UTF-8,
and offsets inside a code point or beyond the response. This option only works
with GET; never repeat a mutation to retrieve another part of its response. A
new write call that carries `responseText` is refused before it is sent, like
`saveResponse`: it never carries request fields, so resend the write once
without it and with its fields in `body`. Older saved calls stay valid for recovery.
with GET; never repeat a mutation to retrieve another part of its response. On
a write, an empty or default `responseText` (`{}` or `offsetBytes: 0`) and
`saveResponse: false` change nothing, so they are dropped and the write is sent
once. A new write whose `responseText` sets a window (`limitBytes` or a later
offset) is refused before it is sent: it never carries request fields, so resend
the write once without it and with its fields in `body`. Older saved calls stay
valid for recovery.
Read the saved artifact for a stable snapshot instead of paging a changing live
response. A text-window call against a live response above 24 KiB also returns
that snapshot's artifact reference; continue on its content operation. A saved
@@ -987,6 +987,12 @@ describe("conversation execution wake policy", () => {
});
describe("chat prompt policy", () => {
// Full chat cohort 5 (questions-and-approvals.reject-and-revise): the person said "Do not publish it", the CEO read
// withdraw as the only agent route, saw the person had created the publish card and left it pending.
it("tells chat to record a person's decline with resolve-from-comment whoever created the card, and keeps withdraw for its own obsolete card", () => {
expect(AGENT_CHAT_DIRECTIVE).toContain("A person's explicit decline in chat (for example \"do not publish it\") answers a pending confirmation with reject, whoever created the card");
expect(AGENT_CHAT_DIRECTIVE).toContain("Withdraw is only for your own card that later evidence made obsolete; it never records the person's decision");
});
it("tells chat to save a requested start order as a blocker on the waiting task, not only a promise to hold it", () => {
expect(AGENT_CHAT_DIRECTIVE).toContain("must not start until another task is done or approved");
expect(AGENT_CHAT_DIRECTIVE).toContain("add the prerequisite to the waiting task's blockedByIssueIds with PATCH /api/issues/{id}, keeping its existing blockers");
+14 -1
View File
@@ -420,6 +420,19 @@ describe("openapi routes", () => {
expect(spec.paths["/api/issues/{id}/interactions/{interactionId}/withdraw"].post["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" });
});
// Full chat cohort 5: the cancel summary named only withdraw for agents, so an agent asked to decline a card the person
// created left it pending. Withdraw is for the agent's own obsolete card; a person's decline goes to resolve-from-comment.
it("points agents to resolve-from-comment to record a person's decline and keeps withdraw for their own obsolete card", () => {
const spec = buildOpenApiSpec() as any;
const interaction = "/api/issues/{id}/interactions/{interactionId}";
for (const summary of [spec.paths[`${interaction}/cancel`].post.summary, spec.paths[`${interaction}/withdraw`].post.summary]) {
expect(summary).toContain("only for the agent's own obsolete card");
expect(summary).toContain(`POST ${interaction}/resolve-from-comment`);
expect(summary).toContain("when the card's resolver policy allows");
}
expect(spec.paths[`${interaction}/resolve-from-comment`].post.summary).toContain("including a decline of a card the person created");
});
it("documents the owned pending-question read as canonical attention items without client scope overrides", () => {
const spec = buildOpenApiSpec() as any;
const operation = spec.paths["/api/issues/{id}/pending-questions"].get;
@@ -816,7 +829,7 @@ describe("openapi routes", () => {
expect(
res.body.paths["/api/issues/{id}/interactions/{interactionId}/withdraw"]
.post.summary,
).toBe("Withdraw a pending issue thread interaction");
).toMatch(/^Withdraw a pending issue thread interaction\. /);
const createInteraction =
res.body.paths["/api/issues/{id}/interactions"].post;
expect(createInteraction.description).toContain(
+4 -4
View File
@@ -8678,7 +8678,7 @@ registry.registerPath({
method: "post",
path: "/api/issues/{id}/interactions/{interactionId}/resolve-from-comment",
tags: ["issues"],
summary: "Record a user's conversational confirmation answer",
summary: "Record a user's conversational confirmation answer, including a decline of a card the person created",
description: "An eligible active agent run resolves a confirmation on its own task using the latest user comment. Resolver permissions, target staleness and conversation reset boundaries remain enforced. Matching retries are idempotent. No new wake is scheduled.",
request: {
params: z.object({ id: z.string(), interactionId: z.string() }),
@@ -8760,7 +8760,7 @@ registry.registerPath({
method: "post",
path: "/api/issues/{id}/interactions/{interactionId}/withdraw",
tags: ["issues"],
summary: "Withdraw a pending issue thread interaction",
summary: "Withdraw a pending issue thread interaction. For agents, withdraw is only for the agent's own obsolete card; to decline or answer a card on the person's explicit instruction, an agent records the person's comment with POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment when the card's resolver policy allows",
request: {
params: z.object({ id: z.string(), interactionId: z.string() }),
body: jsonBody(withdrawIssueThreadInteractionSchema),
@@ -11491,7 +11491,7 @@ registerCurrentRoute({
method: "post",
path: "/api/issues/{id}/interactions/{interactionId}/cancel",
tags: ["issues"],
summary: "Cancel an issue question interaction (board users only; an agent withdraws its own pending card with POST /api/issues/{id}/interactions/{interactionId}/withdraw)",
summary: "Cancel an issue question interaction (board users only). Agents: POST /api/issues/{id}/interactions/{interactionId}/withdraw is only for the agent's own obsolete card; to decline or answer a card on the person's explicit instruction, an agent records the person's comment with POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment when the card's resolver policy allows",
body: cancelIssueThreadInteractionSchema,
});
@@ -11514,7 +11514,7 @@ registerCurrentRoute({
method: "post",
path: "/api/issues/{id}/interactions/{interactionId}/withdraw",
tags: ["issues"],
summary: "Withdraw a pending issue thread interaction",
summary: "Withdraw a pending issue thread interaction. For agents, withdraw is only for the agent's own obsolete card; to decline or answer a card on the person's explicit instruction, an agent records the person's comment with POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment when the card's resolver policy allows",
body: withdrawIssueThreadInteractionSchema,
});
+1 -1
View File
@@ -112,7 +112,7 @@ When a tool or save receipt rejects an action, tell the user which action failed
When the user asks to approve a plan before handoff, publish the plan and create a revision-bound approval card before ending the turn. With native tools, call request_human_input using interactionKind: "confirmation", targetRevisionId from the saved document's latestRevisionId, a revision-specific idempotencyKey, and continuationPolicy: "wake_assignee". Set payload.target to { type: "issue_document", key: "plan", revisionId: latestRevisionId }. Through the HTTP API, POST the equivalent request_confirmation interaction to /api/issues/{issueId}/interactions. A written request to approve in your reply does not create an approval card. After requested revisions, create a fresh card for the newly saved revision. This applies to explicitly requested plan approval; ordinary conversation replies and draft planning do not need confirmation. In Ask mode, discuss the plan without creating or revising documents or approval cards.
When a user answers a pending confirmation in chat, read the latest cards and user comments, identify the specific proposal and decision, and persist it before acting: POST /api/issues/{issueId}/interactions/{interactionId}/resolve-from-comment with { commentId: the user message ID, decision: "accept" or "reject" }. Use call_api on native runners. For a checkbox confirmation, also send selectedOptionIds for the choices the user actually approved; defaults alone are not consent. An ambiguous "yes" with multiple pending proposals requires clarification, not approving all of them. A requested revision is not acceptance. Do not use this endpoint for question forms or governed tool/secret approvals. Respect resolver-policy denials. If saving the decision fails, re-read the card and retry the same decision when appropriate before doing the approved work; never leave it pending and proceed anyway. If a card is already resolved, use its recorded outcome. Do not ask the user to clear a card after recording their answer.
When a user answers a pending confirmation in chat, read the latest cards and user comments, identify the specific proposal and decision, and persist it before acting: POST /api/issues/{issueId}/interactions/{interactionId}/resolve-from-comment with { commentId: the user message ID, decision: "accept" or "reject" }. Use call_api on native runners. For a checkbox confirmation, also send selectedOptionIds for the choices the user actually approved; defaults alone are not consent. An ambiguous "yes" with multiple pending proposals requires clarification, not approving all of them. A requested revision is not acceptance. Do not use this endpoint for question forms or governed tool/secret approvals. Respect resolver-policy denials. A person's explicit decline in chat (for example "do not publish it") answers a pending confirmation with reject, whoever created the card: record it with resolve-from-comment when the card's resolver policy allows, then do the revised work. Withdraw is only for your own card that later evidence made obsolete; it never records the person's decision. If saving the decision fails, re-read the card and retry the same decision when appropriate before doing the approved work; never leave it pending and proceed anyway. If a card is already resolved, use its recorded outcome. Do not ask the user to clear a card after recording their answer.
Before handing off work, inspect available projects and repositories. Every task you create from this chat must belong to a suitable project. Reuse an appropriate existing project; otherwise use create_project. Consider all relevant available repositories and pass repositoryIds for one or multiple repositories when the work spans them. For existing GitHub repositories you can access that are absent from the catalog, pass their HTTPS repositoryUrls; this registers them with the project without creating remote GitHub repositories. You may combine known IDs and URLs and attach multiple repositories. The direct HTTP equivalent is POST /api/companies/{companyId}/projects with name, repositoryIds and/or repositoryUrls arrays, and an idempotencyKey. Include all selected repositories in that creation; do not combine these arrays with workspace. Never invent repository IDs or substitute inaccessible repositories. Ask when the choice is materially ambiguous or required access is missing. Non-code projects may need no repository.
@@ -1221,7 +1221,7 @@ export class PaperclipRunnerToolAuthority {
}
// A new call that would be rejected before dispatch must not reserve a
// pending receipt; that would make its callId report an unknown outcome.
assertRunnerApiDispatchable(operation, input, Object.keys(record(value)));
assertRunnerApiDispatchable(operation, input, record(value));
if (Object.keys(receipts).length >= 512) throw badRequest("Run API mutation limit reached");
receipts[key] = { digest, operationId: operation.operationId, state: "pending" };
await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, apiToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId));
@@ -88,15 +88,21 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext)
* older accepted call with validateRunnerApiCall, so these rules stay out of it.
* Callers that reserve a mutation receipt must run this before the reservation.
*/
export function assertRunnerApiDispatchable(operation: RunnerApiOperation, input: RunnerApiCall, receivedKeys: readonly string[]): void {
assertBodyPresent(operation, input, receivedKeys);
// Like saveResponse, responseText is a GET read option. On a write it changes nothing, and models that send it
// there tend to put request fields beside it instead of in body; refuse before sending and say where they go.
if (operation.method !== "GET" && receivedKeys.includes("responseText")) {
export function assertRunnerApiDispatchable(operation: RunnerApiOperation, input: RunnerApiCall, received: Readonly<Record<string, unknown>>): void {
assertBodyPresent(operation, input, Object.keys(received));
// responseText is a GET read option; validateRunnerApiCall drops it from a write. An empty or default one
// ({} or offsetBytes 0) changes nothing, so the write is sent. One that sets a window shows the model expects it
// to shape the write, and such calls tend to put request fields beside it; refuse before sending.
if (operation.method !== "GET" && setsResponseWindow(received.responseText)) {
throw badRequest(`This request was not sent: responseText applies only to GET reads and never carries request fields. Resend ${operation.operationId} once without responseText, with its request fields in body${requestFieldHint(operation)}.`);
}
}
function setsResponseWindow(value: unknown): boolean {
if (!value || typeof value !== "object") return false;
return Object.entries(value).some(([key, setting]) => setting !== undefined && !(key === "offsetBytes" && setting === 0));
}
function assertBodyPresent(operation: RunnerApiOperation, input: RunnerApiCall, receivedKeys: readonly string[]): void {
if (input.body !== undefined || input.files?.length || !operation.requestBody?.required) return;
// A PATCH without a body succeeds as a no-op that reads like a completed
@@ -200,7 +206,7 @@ export function runnerApiPagination(headers: Headers) {
export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiContext, io: RunnerApiIo) {
const inlineBytes = RUNNER_API_INLINE_BYTES;
const { operation, input: validated } = validateRunnerApiCall(input, context);
assertRunnerApiDispatchable(operation, validated, Object.keys(input));
assertRunnerApiDispatchable(operation, validated, input);
input = validated;
const sensitiveResponse = runnerApiSensitiveResponse(operation.method, operation.path);
const url = runnerApiUrl(operation, input, context, io.apiUrl);
@@ -811,9 +811,9 @@ describe("runner API request boundary", () => {
await expect(executeRunnerApi({ operationId: createProject, body: { name: "Once" }, responseText: { offsetBytes: 4 } }, context, io(request))).rejects.toThrow("This request was not sent");
expect(request).not.toHaveBeenCalled();
});
// Full chat cohort 4: write calls carried responseText beside (or instead of) their body. Like saveResponse, a new
// write with responseText is refused before sending; an older saved call still validates for recovery.
it.each([{}, { limitBytes: 8192 }, { offsetBytes: 0, limitBytes: 4096 }])("refuses a new write that carries responseText before sending: %j", async responseText => {
// Full chat cohort 4: write calls carried responseText beside (or instead of) their body. A new write whose
// responseText sets a window is refused before sending; an older saved call still validates for recovery.
it.each([{ limitBytes: 8192 }, { offsetBytes: 0, limitBytes: 4096 }])("refuses a new write whose responseText sets a window before sending: %j", async responseText => {
const request = vi.fn<typeof fetch>(async () => Response.json({ id: "company-a", name: "Acorn Studio" }));
const call = { operationId: "PATCH /api/companies/{companyId}", pathParams: { companyId: "company-a" }, body: { name: "Acorn Studio" }, responseText };
const error = await executeRunnerApi(call, context, io(request)).then(() => null, (caught: Error) => caught);
@@ -822,6 +822,25 @@ describe("runner API request boundary", () => {
expect(error?.message).toContain("Resend PATCH /api/companies/{companyId} once without responseText, with its request fields in body");
expect(validateRunnerApiCall(call, context).input).not.toHaveProperty("responseText");
});
// Full chat cohort 5 (inbox-search.restore-archive): DeepSeek sent responseText: {} with a valid body on every
// inbox-archive POST, and 24 refusals in a row ran out the chat window. An empty or default read option changes
// nothing on a write, so it is dropped and the write is sent once.
it.each([
{ responseText: {} },
{ responseText: { offsetBytes: 0 } },
{ saveResponse: false },
{ responseText: {}, saveResponse: false },
])("sends a write once when its read options are empty or default: %j", async options => {
const request = vi.fn<typeof fetch>(async () => Response.json({ ok: true }));
const result = await executeRunnerApi({ operationId: "POST /api/issues/{id}/inbox-archive", pathParams: { id: "issue-a" }, body: {}, ...options }, context, io(request));
expect(request).toHaveBeenCalledTimes(1);
const [url, init] = request.mock.calls[0]!;
expect(String(url)).toMatch(/\/api\/issues\/issue-a\/inbox-archive$/);
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toEqual({});
expect(result).toMatchObject({ ok: true, status: 200 });
expect(result).not.toHaveProperty("responseText");
});
it("still sends the same write once without responseText", async () => {
const request = vi.fn<typeof fetch>(async () => Response.json({ id: "company-a", name: "Acorn Studio" }));
const result = await executeRunnerApi({ operationId: "PATCH /api/companies/{companyId}", pathParams: { companyId: "company-a" }, body: { name: "Acorn Studio" } }, context, io(request));