Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/ai/src/api/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
198 changes: 197 additions & 1 deletion packages/ai/test/anthropic-mid-conversation-effort.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -11,6 +12,8 @@ interface WireMessage {

interface CapturedPayload {
messages: WireMessage[];
system?: unknown;
tools?: unknown;
thinking?: {
type: string;
display?: string;
Expand Down Expand Up @@ -82,13 +85,206 @@ async function capture(
return { payload, message };
}

async function captureFinalRequest(
model: Model<"anthropic-messages">,
context: Context,
options: Pick<AnthropicOptions, "thinkingEnabled" | "effort">,
): Promise<CapturedPayload> {
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");
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down