fix: handle pause_turn stop reason to prevent 400 errors with native web search (#2869) (#3248)

Map pause_turn to "pauseTurn" instead of "stop" so the agent loop
continues when Anthropic's server pauses a long-running turn (e.g.
native web search hitting its iteration limit). Previously the
incomplete server_tool_use block was saved to history, causing a
400 invalid_request_error on the next API call.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 15:51:18 -04:00 committed by GitHub
parent 341a211be2
commit 2ea668ee09
6 changed files with 81 additions and 6 deletions

View file

@ -0,0 +1,45 @@
// agent-loop pauseTurn handling tests
// Verifies that pause_turn / pauseTurn stop reason causes the inner loop
// to continue (re-invoke the LLM) instead of exiting.
// Regression test for https://github.com/gsd-build/gsd-2/issues/2869
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
describe("agent-loop — pauseTurn handling (#2869)", () => {
it("sets hasMoreToolCalls when stopReason is pauseTurn", () => {
const source = readFileSync(join(__dirname, "agent-loop.ts"), "utf-8");
// The agent loop must treat pauseTurn as a reason to continue the inner
// loop, just like toolUse. This prevents incomplete server_tool_use blocks
// from being saved to history, which would cause a 400 on the next request.
assert.match(
source,
/pauseTurn/,
"agent-loop.ts must handle the pauseTurn stop reason",
);
// Verify it sets hasMoreToolCalls = true for pauseTurn
assert.match(
source,
/stopReason\s*===?\s*["']pauseTurn["']/,
'agent-loop.ts must check for stopReason === "pauseTurn"',
);
});
it("pauseTurn is in the StopReason union type", () => {
// Read the pi-ai types to ensure pauseTurn is a valid StopReason
const typesPath = join(__dirname, "..", "..", "pi-ai", "src", "types.ts");
const typesSource = readFileSync(typesPath, "utf-8");
assert.match(
typesSource,
/["']pauseTurn["']/,
'StopReason type must include "pauseTurn"',
);
});
});

View file

@ -231,9 +231,10 @@ async function runLoop(
return;
}
// Check for tool calls
// Check for tool calls or paused server turn
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = toolCalls.length > 0;
hasMoreToolCalls =
toolCalls.length > 0 || message.stopReason === "pauseTurn";
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls && config.externalToolExecution) {

View file

@ -47,7 +47,7 @@ export type ProxyAssistantMessageEvent =
| { type: "toolcall_end"; contentIndex: number }
| {
type: "done";
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
reason: Extract<StopReason, "stop" | "length" | "toolUse" | "pauseTurn">;
usage: AssistantMessage["usage"];
}
| {

View file

@ -0,0 +1,29 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mapStopReason } from "./anthropic-shared.js";
describe("mapStopReason", () => {
it("maps end_turn to stop", () => {
assert.equal(mapStopReason("end_turn"), "stop");
});
it("maps max_tokens to length", () => {
assert.equal(mapStopReason("max_tokens"), "length");
});
it("maps tool_use to toolUse", () => {
assert.equal(mapStopReason("tool_use"), "toolUse");
});
it("maps pause_turn to pauseTurn (not stop)", () => {
// pause_turn means the server paused a long-running turn (e.g. native
// web search hit its iteration limit). Mapping it to "stop" causes the
// agent loop to exit, leaving an incomplete server_tool_use block in
// history which triggers a 400 on the next request.
assert.equal(mapStopReason("pause_turn"), "pauseTurn");
});
it("throws on unknown stop reason", () => {
assert.throws(() => mapStopReason("bogus"), /Unhandled stop reason/);
});
});

View file

@ -502,7 +502,7 @@ export function mapStopReason(reason: string): StopReason {
case "refusal":
return "error";
case "pause_turn":
return "stop";
return "pauseTurn";
case "stop_sequence":
return "stop";
case "sensitive":

View file

@ -192,7 +192,7 @@ export interface Usage {
};
}
export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
export type StopReason = "stop" | "length" | "toolUse" | "pauseTurn" | "error" | "aborted";
export interface UserMessage {
role: "user";
@ -253,7 +253,7 @@ export type AssistantMessageEvent =
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage; malformedArguments?: boolean }
| { type: "server_tool_use"; contentIndex: number; partial: AssistantMessage }
| { type: "web_search_result"; contentIndex: number; partial: AssistantMessage }
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse" | "pauseTurn">; message: AssistantMessage }
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
/**