From d44b793a911c2482424c95084e4441c048cbb46b Mon Sep 17 00:00:00 2001 From: kitlangton <7587245+kitlangton@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:52:57 +0000 Subject: [PATCH] test(core): cover Anthropic partial recovery --- ...ed-tool-call-followed-by-continuation.json | 35 +++++++ .../anthropic-recovery.recorded.test.ts | 59 +++++++++++ .../test/session-anthropic-recovery.test.ts | 99 +++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 packages/ai/test/fixtures/recordings/anthropic-recovery/accepts-a-synthetic-settled-tool-call-followed-by-continuation.json create mode 100644 packages/ai/test/provider/anthropic-recovery.recorded.test.ts create mode 100644 packages/core/test/session-anthropic-recovery.test.ts diff --git a/packages/ai/test/fixtures/recordings/anthropic-recovery/accepts-a-synthetic-settled-tool-call-followed-by-continuation.json b/packages/ai/test/fixtures/recordings/anthropic-recovery/accepts-a-synthetic-settled-tool-call-followed-by-continuation.json new file mode 100644 index 00000000000..fac0cc4cfe4 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/anthropic-recovery/accepts-a-synthetic-settled-tool-call-followed-by-continuation.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:anthropic-recovery", + "provider:anthropic", + "protocol:anthropic-messages", + "tool", + "recovery" + ], + "name": "anthropic-recovery/accepts-a-synthetic-settled-tool-call-followed-by-continuation", + "recordedAt": "2026-08-22T01:52:23.916Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://opencode.ai/zen/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-sonnet-4-6\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the weather in Paris.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I should use the weather tool.\"},{\"type\":\"tool_use\",\"id\":\"call_weather\",\"name\":\"lookup_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_weather\",\"content\":\"sunny\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":128,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_011CeGx6WkAQP5ULyRRbRwRw\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":664,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in Paris is currently **sunny**! It looks like a great day to enjoy the city. ☀️\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":664,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":28} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\nevent: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n" + } + } + ] +} diff --git a/packages/ai/test/provider/anthropic-recovery.recorded.test.ts b/packages/ai/test/provider/anthropic-recovery.recorded.test.ts new file mode 100644 index 00000000000..d9ba1ceebf9 --- /dev/null +++ b/packages/ai/test/provider/anthropic-recovery.recorded.test.ts @@ -0,0 +1,59 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, Message, ToolCallPart, ToolDefinition } from "../../src/index.js" +import { configure } from "../../src/providers/anthropic.js" +import { LLMClient } from "../../src/route.js" +import { recordedTests } from "../recorded-test.js" + +const model = configure({ + apiKey: process.env.ZEN_TEST_API_KEY ?? "fixture", + baseURL: "https://opencode.ai/zen/v1", +}).model("claude-sonnet-4-6") +const continuation = + "The previous response was interrupted. Continue from where you left off without repeating completed content." +const lookup = ToolDefinition.make({ + name: "lookup_weather", + description: "Return benign weather data for a city.", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, +}) +const recorded = recordedTests({ + prefix: "anthropic-recovery", + provider: "anthropic", + protocol: "anthropic-messages", + requires: ["ZEN_TEST_API_KEY"], + options: { redact: { allowRequestHeaders: ["anthropic-version"] } }, +}) + +describe("Anthropic interrupted recovery recorded", () => { + recorded.effect.with( + "accepts a synthetic settled tool call followed by continuation", + { tags: ["tool", "recovery"] }, + () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + id: "recorded_anthropic_interrupted_tool_recovery", + model, + messages: [ + Message.user("Look up the weather in Paris."), + Message.assistant([ + { type: "text", text: "I should use the weather tool." }, + ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }), + ]), + Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" }), + Message.user(continuation), + ], + tools: [lookup], + toolChoice: "none", + generation: { maxTokens: 128, temperature: 0 }, + }), + ) + + expect(response.text.trim().length).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/core/test/session-anthropic-recovery.test.ts b/packages/core/test/session-anthropic-recovery.test.ts new file mode 100644 index 00000000000..347c0f168db --- /dev/null +++ b/packages/core/test/session-anthropic-recovery.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "bun:test" +import { LLM } from "@opencode-ai/ai" +import { configure } from "@opencode-ai/ai/providers/anthropic" +import { compileRequest } from "@opencode-ai/ai/route/client" +import { Agent } from "@opencode-ai/core/agent" +import { Model } from "@opencode-ai/core/model" +import { Provider } from "@opencode-ai/core/provider" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" +import { DateTime, Effect } from "effect" + +const created = DateTime.makeUnsafe(0) +const anthropic = Model.Ref.make({ + id: Model.ID.make("claude-sonnet-4-6"), + providerID: Provider.ID.make("anthropic"), +}) +const continuation = + "The previous response was interrupted. Continue from where you left off without repeating completed content." + +test("failed Anthropic tool history replays through the public request boundary", async () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: SessionMessage.ID.make("msg_user"), + type: "user", + text: "Look up the weather.", + time: { created }, + }), + SessionMessage.Assistant.make({ + id: SessionMessage.ID.make("msg_failed"), + type: "assistant", + agent: Agent.defaultID, + model: anthropic, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + text: "I should use the weather tool.", + state: { signature: "signed-reasoning" }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "call_weather", + name: "lookup_weather", + executed: false, + state: SessionMessage.ToolStateCompleted.make({ + status: "completed", + input: { city: "Paris" }, + content: [{ type: "text", text: "sunny" }], + }), + time: { created, completed: created }, + }), + ], + finish: "error", + error: { type: "provider.internal", message: "Provider stream failed" }, + time: { created, completed: created }, + }), + SessionMessage.Synthetic.make({ + id: SessionMessage.ID.make("msg_continue"), + type: "synthetic", + text: continuation, + time: { created }, + }), + ], + anthropic, + ) + const prepared = await Effect.runPromise( + compileRequest( + LLM.request({ + model: configure({ apiKey: "test" }).model("claude-sonnet-4-6"), + messages, + }), + ), + ) + + expect(messages).toMatchObject([ + { role: "user" }, + { + role: "assistant", + content: [ + { type: "text", text: "I should use the weather tool." }, + { type: "tool-call", id: "call_weather", providerMetadata: undefined }, + ], + }, + { role: "tool", content: [{ type: "tool-result", id: "call_weather" }] }, + { role: "user", content: [{ type: "text", text: continuation }] }, + ]) + expect(prepared.body.messages).toMatchObject([ + { role: "user", content: [{ type: "text", text: "Look up the weather." }] }, + { + role: "assistant", + content: [ + { type: "text", text: "I should use the weather tool." }, + { type: "tool_use", id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_weather", content: "sunny" }] }, + { role: "user", content: [{ type: "text", text: continuation }] }, + ]) +})