diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1d8c5a31c7..e3fe956c91 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Remove per-turn Anthropic effort markers when thinking is disabled explicitly or for a cross-model tool continuation, without changing enabled xhigh/max reasoning or models that cannot disable thinking. + ### Removed ## [2026.9.5-3] - 2026-09-05 diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 2cb3a92419..80a34ba2ff 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -1796,6 +1796,16 @@ function disableThinkingForRequest( return; } params.thinking = { type: "disabled" }; + // Generated per-turn effort markers are incompatible with disabled thinking too. + params.messages = params.messages.filter( + (message) => + !( + message.role === "system" && + Array.isArray(message.content) && + message.content.length === 0 && + message.output_config?.effort !== undefined + ), + ); } function supportsAdaptiveThinking(model: Model<"anthropic-messages">): boolean { diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index 41d39a530c..4836a58193 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,4 +1,23 @@ +## 2026-09-05 - Remove effort markers only when Anthropic thinking is disabled + +### What changed + +- `packages/ai/src/api/anthropic-messages.ts`: `disableThinkingForRequest()` removes generated empty-content, effort-only system messages when it emits `thinking: { type: "disabled" }`. The cannot-disable early return, enabled xhigh/max markers, unrelated history, tool pairs, cache checkpoints, and caller-owned context remain unchanged. +- `packages/ai/test/anthropic-mid-conversation-effort.test.ts`: captures final SDK fetch bodies for historical xhigh with explicit thinking-off, cross-model tool continuation, enabled xhigh/max, and cannot-disable family/compat gates. + +### Why + +- `packages/ai/src/api/anthropic-messages.ts` inserts historical and current effort markers before selecting thinking configuration. Removing only top-level effort left incompatible per-turn effort in requests disabled explicitly or degraded after cross-model signed-thinking loss. + +### Why an extension could not handle it + +- `packages/ai/src/api/anthropic-messages.ts` owns both generated wire markers and the request-local disable decision. Filtering at that decision keeps persisted history intact and avoids globally clamping enabled reasoning or changing models that cannot disable thinking. + +### Expected merge conflict zones + +- LOW: `packages/ai/src/api/anthropic-messages.ts` in `disableThinkingForRequest()` after the cannot-disable early return, alongside the generated marker shape in `insertThinkingLevelMessages()`. + ## 2026-09-05 - Project Astra configuration updates at the Responses wire ### What changed diff --git a/packages/ai/test/anthropic-mid-conversation-effort.test.ts b/packages/ai/test/anthropic-mid-conversation-effort.test.ts index 10461f8598..cbb5a5720f 100644 --- a/packages/ai/test/anthropic-mid-conversation-effort.test.ts +++ b/packages/ai/test/anthropic-mid-conversation-effort.test.ts @@ -1,5 +1,6 @@ +import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { stream } from "../src/api/anthropic-messages.ts"; +import { type AnthropicOptions, stream } from "../src/api/anthropic-messages.ts"; import { getModel } from "../src/compat.ts"; import type { AssistantMessage, Context, Model } from "../src/types.ts"; @@ -11,6 +12,8 @@ interface WireMessage { interface CapturedPayload { messages: WireMessage[]; + system?: unknown; + tools?: unknown; thinking?: { type: string; display?: string; @@ -82,13 +85,206 @@ async function capture( return { payload, message }; } +async function captureFinalRequest( + model: Model<"anthropic-messages">, + context: Context, + options: Pick, +): Promise { + const payloads: CapturedPayload[] = []; + const events = [ + { + type: "message_start", + message: { id: "msg_test", model: model.id, usage: { input_tokens: 1, output_tokens: 0 } }, + }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]; + const body = events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""); + const message = await stream({ ...model, baseUrl: "http://127.0.0.1:9" }, context, { + ...options, + apiKey: "test-key", + cacheRetention: "short", + maxRetries: 0, + timeoutMs: 1000, + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + payloads.push((await request.json()) as CapturedPayload); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); + }, + }).result(); + expect(message.stopReason, message.errorMessage).toBe("stop"); + expect(payloads).toHaveLength(1); + return payloads[0]; +} + const user = (text: string, timestamp: number) => ({ role: "user" as const, content: text, timestamp }); +function toolContinuationContext(model: Model<"anthropic-messages">, toolModel: Model<"anthropic-messages">): Context { + return { + systemPrompt: "Keep the conversation context.", + tools: [{ name: "read", description: "Read a file", parameters: Type.Object({ path: Type.String() }) }], + messages: [ + user("one", 1), + assistant(model, "xhigh"), + user("read a file", 2), + { + ...assistant(toolModel, "xhigh"), + content: [ + { type: "thinking", thinking: "reasoning", thinkingSignature: "signature" }, + { type: "toolCall", id: "toolu_read", name: "read", arguments: { path: "README.md" } }, + ], + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_read", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + isError: false, + timestamp: 3, + }, + ], + }; +} + function effortMessages(payload: CapturedPayload): WireMessage[] { return payload.messages.filter((message) => message.role === "system"); } describe("Anthropic mid-conversation effort", () => { + it("removes historical xhigh and current effort from the final request when thinking is explicitly off", async () => { + const model = getModel("anthropic", "claude-opus-5"); + const context: Context = { + systemPrompt: "Keep the conversation context.", + messages: [user("one", 1), assistant(model, "xhigh"), user("two", 2)], + }; + const original = structuredClone(context); + const payload = await captureFinalRequest(model, context, { thinkingEnabled: false, effort: "max" }); + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.output_config).toBeUndefined(); + expect(effortMessages(payload)).toEqual([]); + expect(payload.messages).toEqual([ + { role: "user", content: "one" }, + { role: "assistant", content: [{ type: "text", text: "answer" }] }, + { role: "user", content: [{ type: "text", text: "two", cache_control: { type: "ephemeral" } }] }, + ]); + expect(payload.system).toEqual([ + { type: "text", text: context.systemPrompt, cache_control: { type: "ephemeral" } }, + ]); + expect(context).toEqual(original); + }); + + it("removes effort markers from the final cross-model tool continuation without changing tool pairs or cache checkpoints", async () => { + const model = getModel("anthropic", "claude-opus-5"); + const context = toolContinuationContext(model, managedModel()); + const original = structuredClone(context); + const payload = await captureFinalRequest(model, context, { thinkingEnabled: true, effort: "max" }); + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.output_config).toBeUndefined(); + expect(effortMessages(payload)).toEqual([]); + expect(payload.messages).toEqual([ + { role: "user", content: "one" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning", signature: "signature" }, + { type: "text", text: "answer" }, + ], + }, + { role: "user", content: [{ type: "text", text: "read a file", cache_control: { type: "ephemeral" } }] }, + { + role: "assistant", + content: [ + { type: "text", text: "reasoning" }, + { type: "tool_use", id: "toolu_read", name: "read", input: { path: "README.md" } }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_read", + content: "file contents", + is_error: false, + cache_control: { type: "ephemeral" }, + }, + ], + }, + ]); + expect(payload.system).toEqual([ + { type: "text", text: context.systemPrompt, cache_control: { type: "ephemeral" } }, + ]); + expect(payload.tools).toEqual([expect.objectContaining({ name: "read", cache_control: { type: "ephemeral" } })]); + expect(context).toEqual(original); + }); + + it.each(["xhigh", "max"] as const)( + "preserves enabled %s and historical markers through the final fetch", + async (effort) => { + const model = getModel("anthropic", "claude-opus-5"); + const context = toolContinuationContext(model, model); + const original = structuredClone(context); + const payload = await captureFinalRequest(model, context, { thinkingEnabled: true, effort }); + + expect(payload.thinking).toEqual({ + type: "adaptive", + display: "summarized", + block_binding: { prefix_mismatch_behavior: "drop_block" }, + }); + expect(payload.output_config).toEqual({ effort: "high" }); + expect(effortMessages(payload)).toEqual([ + { role: "system", content: [], output_config: { effort: "xhigh" } }, + { role: "system", content: [], output_config: { effort: "xhigh" } }, + { role: "system", content: [], output_config: { effort } }, + ]); + expect(payload.messages[5]).toEqual({ + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning", signature: "signature" }, + { type: "tool_use", id: "toolu_read", name: "read", input: { path: "README.md" } }, + ], + }); + expect(payload.messages[6]).toEqual({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_read", + content: "file contents", + is_error: false, + cache_control: { type: "ephemeral" }, + }, + ], + }); + expect(context).toEqual(original); + }, + ); + + it.each(["family", "compat"] as const)( + "preserves markers when the %s gate prevents disabling thinking", + async (gate) => { + const base = getModel("anthropic", "claude-opus-5"); + const model = + gate === "family" + ? managedModel() + : { ...base, compat: { ...base.compat, supportsDisabledThinking: false } }; + const context: Context = { messages: [user("one", 1), assistant(model, "xhigh"), user("two", 2)] }; + const original = structuredClone(context); + const payload = await captureFinalRequest(model, context, { thinkingEnabled: false, effort: "max" }); + + expect(payload.thinking).toBeUndefined(); + expect(payload.output_config).toEqual({ effort: "low" }); + expect(effortMessages(payload)).toEqual([ + { role: "system", content: [], output_config: { effort: "xhigh" } }, + { role: "system", content: [], output_config: { effort: "max" } }, + ]); + expect(context).toEqual(original); + }, + ); + it("reconstructs an exact historical marker prefix and appends the current marker", async () => { const model = managedModel(); const first = await capture(model, { messages: [user("one", 1)] }, "low"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1ef92f4c3e..7a0f308657 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Remove per-turn Anthropic effort markers when thinking is disabled explicitly or for a cross-model tool continuation, without changing enabled xhigh/max reasoning or models that cannot disable thinking. + ### Removed ## [2026.9.5-3] - 2026-09-05