Skip to content

Commit 8cf0b72

Browse files
committed
fix(transform): preserve thinking-block integrity on Opus 4.7
Two related fixes for Anthropic's stricter thinking-block validation that landed with Claude Opus 4.7. The same long-history session that worked fine on Opus 4.6 was rejected by 4.7 with: 'thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.' 1. Preserve dropped user-message shells. Removing a user message collapses turn boundaries so @ai-sdk/anthropic merges adjacent assistants into one Anthropic assistant block, which invalidates the signed thinking history inside. Dropped user content is now replaced with a [truncated §N§] preview (keeping the shell) rather than removing the message. 2. Workaround @ai-sdk/anthropic's groupIntoBlocks producing interleaved [thinking, text, thinking, text, ...] layouts when multiple consecutive OpenCode assistants each carry signed reasoning. The new stripReasoningFromMergedAssistants pass strips reasoning from all but the first assistant in each consecutive run so the merged block keeps thinking at position 0. Upstream bug tracked for removal when @ai-sdk/anthropic is fixed (same class as Bedrock #13583/#13585/#13972). Also: - nudge-injection no longer targets thinking-bearing assistants; file parts are no longer treated as metadata-only content. - Mock-provider now streams thinking_delta + signature_delta and advertises image/PDF modalities so the new e2e suite can exercise the live OpenCode + Anthropic-wire regressions end-to-end.
1 parent aa536b2 commit 8cf0b72

10 files changed

Lines changed: 1200 additions & 14 deletions

File tree

packages/e2e-tests/src/mock-provider/server.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,13 @@ export class MockProvider {
235235

236236
// Emit each content block from the scripted `content` array.
237237
content.forEach((block: unknown, index: number) => {
238-
const blk = block as { type?: string; text?: string };
238+
const blk = block as {
239+
type?: string;
240+
text?: string;
241+
thinking?: string;
242+
signature?: string;
243+
data?: string;
244+
};
239245
const blockType = blk.type ?? "text";
240246

241247
if (blockType === "text") {
@@ -253,8 +259,59 @@ export class MockProvider {
253259
type: "content_block_stop",
254260
index,
255261
});
262+
} else if (blockType === "thinking") {
263+
// Real Anthropic streams thinking as:
264+
// 1. content_block_start { content_block: { type: "thinking", thinking: "" } }
265+
// 2. content_block_delta { delta: { type: "thinking_delta", thinking: "..." } }
266+
// 3. content_block_delta { delta: { type: "signature_delta", signature: "..." } }
267+
// 4. content_block_stop
268+
// @ai-sdk/anthropic reconstructs the reasoning part from these deltas.
269+
send("content_block_start", {
270+
type: "content_block_start",
271+
index,
272+
content_block: { type: "thinking", thinking: "" },
273+
});
274+
if (blk.thinking) {
275+
send("content_block_delta", {
276+
type: "content_block_delta",
277+
index,
278+
delta: {
279+
type: "thinking_delta",
280+
thinking: blk.thinking,
281+
},
282+
});
283+
}
284+
if (blk.signature) {
285+
send("content_block_delta", {
286+
type: "content_block_delta",
287+
index,
288+
delta: {
289+
type: "signature_delta",
290+
signature: blk.signature,
291+
},
292+
});
293+
}
294+
send("content_block_stop", {
295+
type: "content_block_stop",
296+
index,
297+
});
298+
} else if (blockType === "redacted_thinking") {
299+
// Redacted thinking carries opaque `data` in the start event;
300+
// no deltas are emitted — the full payload arrives up front.
301+
send("content_block_start", {
302+
type: "content_block_start",
303+
index,
304+
content_block: {
305+
type: "redacted_thinking",
306+
data: blk.data ?? "",
307+
},
308+
});
309+
send("content_block_stop", {
310+
type: "content_block_stop",
311+
index,
312+
});
256313
} else {
257-
// Pass through non-text blocks as-is (tool_use, etc.)
314+
// Pass through other non-text blocks as-is (tool_use, etc.)
258315
send("content_block_start", {
259316
type: "content_block_start",
260317
index,

packages/e2e-tests/src/opencode-runner/spawn.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ function writeConfigs(
112112
name: "Mock Sonnet",
113113
cost: { input: 0, output: 0 },
114114
limit: { context: opts.modelContextLimit ?? 200000, output: 8192 },
115+
// Advertise image + pdf input support so OpenCode does
116+
// not substitute inline file parts with "this model
117+
// does not support X input" text messages. Matches the
118+
// real Sonnet capabilities this mock is standing in for.
119+
modalities: {
120+
input: ["text", "image", "pdf"],
121+
output: ["text"],
122+
},
115123
options: {},
116124
},
117125
},

0 commit comments

Comments
 (0)