diff --git a/packages/pi-agent-core/src/agent-loop.test.ts b/packages/pi-agent-core/src/agent-loop.test.ts new file mode 100644 index 000000000..0e61d9701 --- /dev/null +++ b/packages/pi-agent-core/src/agent-loop.test.ts @@ -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"', + ); + }); +}); diff --git a/packages/pi-agent-core/src/agent-loop.ts b/packages/pi-agent-core/src/agent-loop.ts index fad23b145..82254d3bf 100644 --- a/packages/pi-agent-core/src/agent-loop.ts +++ b/packages/pi-agent-core/src/agent-loop.ts @@ -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) { diff --git a/packages/pi-agent-core/src/proxy.ts b/packages/pi-agent-core/src/proxy.ts index 619521bda..574ec2bf6 100644 --- a/packages/pi-agent-core/src/proxy.ts +++ b/packages/pi-agent-core/src/proxy.ts @@ -47,7 +47,7 @@ export type ProxyAssistantMessageEvent = | { type: "toolcall_end"; contentIndex: number } | { type: "done"; - reason: Extract; + reason: Extract; usage: AssistantMessage["usage"]; } | { diff --git a/packages/pi-ai/src/providers/anthropic-shared.test.ts b/packages/pi-ai/src/providers/anthropic-shared.test.ts new file mode 100644 index 000000000..9b6718570 --- /dev/null +++ b/packages/pi-ai/src/providers/anthropic-shared.test.ts @@ -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/); + }); +}); diff --git a/packages/pi-ai/src/providers/anthropic-shared.ts b/packages/pi-ai/src/providers/anthropic-shared.ts index 4425df7dd..d7cc0b41d 100644 --- a/packages/pi-ai/src/providers/anthropic-shared.ts +++ b/packages/pi-ai/src/providers/anthropic-shared.ts @@ -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": diff --git a/packages/pi-ai/src/types.ts b/packages/pi-ai/src/types.ts index f4d63e1de..ea3e1491a 100644 --- a/packages/pi-ai/src/types.ts +++ b/packages/pi-ai/src/types.ts @@ -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; message: AssistantMessage } + | { type: "done"; reason: Extract; message: AssistantMessage } | { type: "error"; reason: Extract; error: AssistantMessage }; /**