Skip to content

Commit c2c3375

Browse files
committed
fix(strip-content): preserve thinking parts with no thinking or text fields
Defense-in-depth layer on top of PR #9 (Tom's fix that removed redacted_thinking from CLEARED_REASONING_TYPES). The predicate inside stripClearedReasoning treated any thinking/reasoning part where both `thinking` and `text` fields were undefined as a cleared shell and dropped it. That's correct for legitimate cleared shells (which have both fields explicitly set to "[cleared]"), but wrong for edge-case shapes where a provider emits a thinking-type part carrying only non-standard fields like `data` or `signature`. Dropping such parts from the latest assistant message causes Anthropic to reject the request with 'thinking or redacted_thinking blocks in the latest assistant message cannot be modified'. This adds a 2-line guard: if neither `thinking` nor `text` key is present on the part, we cannot prove it is cleared, so we preserve it. Drop-callback-cleared parts are not affected because they set existing fields to "[cleared]" (keys remain present). No call sites change, no new state, no watermark, no cache-bust risk. Refs: #8, #9
1 parent f8dea45 commit c2c3375

2 files changed

Lines changed: 36 additions & 0 deletions

File tree

packages/plugin/src/hooks/magic-context/strip-content.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,33 @@ describe("strip-content", () => {
213213
});
214214
});
215215
});
216+
217+
describe("#given a thinking part with no thinking or text fields", () => {
218+
describe("#when stripping cleared reasoning", () => {
219+
it("#then preserves it defensively — undefined fields are not a cleared shell", () => {
220+
// Edge-case shape: a future provider (or upstream bug) could
221+
// emit a thinking-type part carrying only non-standard fields
222+
// like `data` or `signature`, with neither `thinking` nor
223+
// `text` set. The old predicate treated "both undefined" as
224+
// "drop", which would mutate the latest assistant message and
225+
// break Anthropic replay. The guard must preserve these
226+
// parts because we cannot prove they are cleared shells.
227+
const undefinedFieldsPart = {
228+
type: "thinking",
229+
signature: "opaque-provider-signature",
230+
};
231+
const textPart = { type: "text", text: "latest response" };
232+
const msg = message("m-latest", "assistant", [undefinedFieldsPart, textPart]);
233+
234+
const stripped = stripClearedReasoning([msg]);
235+
236+
expect(stripped).toBe(0);
237+
expect(msg.parts).toHaveLength(2);
238+
expect(msg.parts[0]).toBe(undefinedFieldsPart);
239+
expect(msg.parts[1]).toBe(textPart);
240+
});
241+
});
242+
});
216243
});
217244

218245
describe("stripInlineThinking", () => {

packages/plugin/src/hooks/magic-context/strip-content.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,15 @@ export function stripClearedReasoning(messages: MessageLike[]): number {
290290
if (!isRecord(part)) return true;
291291
const partType = part.type as string;
292292
if (!CLEARED_REASONING_TYPES.has(partType)) return true;
293+
// Defense-in-depth: if neither `thinking` nor `text` is present on
294+
// the part, we cannot tell whether it's a cleared shell — keep it.
295+
// This protects edge-case thinking shapes (e.g., future providers
296+
// emitting parts with only a `data` or `signature` field) from
297+
// being wrongly dropped. Anthropic requires thinking-like blocks in
298+
// the latest assistant message to be replayed unchanged, and an
299+
// undefined-fields part cannot be known to be cleared, so it is
300+
// not safe to strip it.
301+
if (!("thinking" in part) && !("text" in part)) return true;
293302
const thinking = "thinking" in part ? (part.thinking as string | undefined) : undefined;
294303
const text = "text" in part ? (part.text as string | undefined) : undefined;
295304
return (

0 commit comments

Comments
 (0)