Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/anthropic-thinking-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/ai': patch
'@tanstack/ai-anthropic': patch
---

Send Claude's thinking and tool errors back the way Claude sent them.

- A tool message with `error` now sends `tool_result.is_error: true`, so Claude sees that the tool failed.
- A `redacted_thinking` block is no longer dropped. It becomes a thinking part with `redacted: true`, an empty `content`, and the encrypted data in `signature`. The flag survives the stream, the UI messages, the wire, and stored threads, and the next request sends the block back as `{ type: 'redacted_thinking', data }`.
- On the AG-UI wire, a redacted block is its own reasoning message. Its id starts with `redacted_thinking-`, and the `REASONING_ENCRYPTED_VALUE` event's `entityId` points to that id. An AG-UI client keeps message ids, so it sends the block back as redacted data, not as a signature.
- A thinking block's signature now names its reasoning message in `entityId`, not the step. An AG-UI client attaches the signature to that message, so it can send it back.
- `ThinkingPart` and `ModelMessage['thinking']` have the new optional `redacted` field.
3 changes: 3 additions & 0 deletions docs/chat/thinking-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ interface ThinkingPart {
content: string;
stepId?: string;
signature?: string;
redacted?: boolean;
}
```

The `ThinkingPart` appears in `UIMessage.parts` alongside `TextPart` and `ToolCallPart` entries. As reasoning tokens arrive, its `content` accumulates token by token.

Claude can also send a redacted thinking block. It is encrypted, so it has no text. It arrives as a `ThinkingPart` with `redacted: true`, an empty `content`, and the encrypted data in `signature`. Keep the part in your stored messages: the next request sends it back to Claude unchanged. In a UI, show a short placeholder such as "Thinking hidden" instead of the empty text.

## Enabling Thinking

How you enable thinking depends on the provider.
Expand Down
2 changes: 1 addition & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
"label": "Thinking & Reasoning",
"to": "chat/thinking-content",
"addedAt": "2026-04-15",
"updatedAt": "2026-08-21"
"updatedAt": "2026-09-30"
},
{
"label": "Message Queue",
Expand Down
85 changes: 81 additions & 4 deletions packages/ai-anthropic/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
isFileSource,
normalizeSystemPrompts,
} from '@tanstack/ai'
import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
import {
REDACTED_THINKING_ID_PREFIX,
toRunErrorRawEvent,
} from '@tanstack/ai/adapter-internals'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { convertToolsToProviderFormat } from '../tools/tool-converter'
import { getAnthropicProviderToolKind } from '../tools/anthropic-provider-tool'
Expand Down Expand Up @@ -824,6 +827,7 @@ export class AnthropicTextAdapter<
: typeof toolContent === 'string'
? toolContent
: '',
...(message.error !== undefined && { is_error: true }),
},
],
})
Expand Down Expand Up @@ -952,6 +956,13 @@ export class AnthropicTextAdapter<

for (const thinking of thinkingParts) {
if (!thinking.signature) continue
if (thinking.redacted) {
contentBlocks.push({
type: 'redacted_thinking',
data: thinking.signature,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not store the redacted_thinking blob in signature.

signature is the signature of a normal thinking block's text. This data value is a different field. There is no old client that already replays these blocks: the adapter used to drop them. Reusing signature does not keep a working replay.

An old build of this library ignores redacted and sends { type: 'thinking', thinking: '', signature: <blob> }. That is the wrong block type. A separate field leaves signature empty, so the existing if (!thinking.signature) continue check omits the part. Claude can still refuse that turn, but the request no longer contains a malformed thinking block.

A client that stores the part unchanged and sends it back through this code works either way. A client that keeps only content loses the blob wherever it sits.

Carry the bytes on something like data (through the thinking part, the model message, and the wire), and send that field here as redacted_thinking.data. Leave signature for real signatures. Update the ThinkingPart note in packages/ai/src/types.ts that currently says signature holds this data.

})
continue
}
const block: ThinkingBlockParam = {
type: 'thinking',
thinking: thinking.content,
Expand Down Expand Up @@ -1214,6 +1225,63 @@ export class AnthropicTextAdapter<
timestamp: Date.now(),
stepType: 'thinking',
}
} else if (event.content_block.type === 'redacted_thinking') {
// Encrypted thinking: no text, and its data must go back to
// Anthropic unchanged. It gets its own reasoning message, and the
// encrypted value names that message. The id prefix marks the
// data as a redacted block, not a signature. The step reuses the
// id so the stream processor sees the prefix too.
const redactedId = `${REDACTED_THINKING_ID_PREFIX}${genId()}`
yield {
type: EventType.REASONING_START,
messageId: redactedId,
model,
timestamp: Date.now(),
}
yield {
type: EventType.REASONING_MESSAGE_START,
messageId: redactedId,
role: 'reasoning' as const,
model,
timestamp: Date.now(),
}
yield {
type: EventType.STEP_STARTED,
stepName: redactedId,
stepId: redactedId,
model,
timestamp: Date.now(),
stepType: 'thinking',
}
yield {
type: EventType.STEP_FINISHED,
stepName: redactedId,
stepId: redactedId,
model,
timestamp: Date.now(),
delta: '',
content: '',
}
yield {
type: EventType.REASONING_ENCRYPTED_VALUE,
subtype: 'message' as const,
entityId: redactedId,
encryptedValue: event.content_block.data,
model,
timestamp: Date.now(),
}
yield {
type: EventType.REASONING_MESSAGE_END,
messageId: redactedId,
model,
timestamp: Date.now(),
}
yield {
type: EventType.REASONING_END,
messageId: redactedId,
model,
timestamp: Date.now(),
}
}
} else if (event.type === 'content_block_delta') {
if (event.delta.type === 'text_delta') {
Expand Down Expand Up @@ -1332,8 +1400,10 @@ export class AnthropicTextAdapter<
}
} else if (event.type === 'content_block_stop') {
if (currentBlockType === 'thinking') {
// Emit signature so it can be replayed in multi-turn context
if (accumulatedSignature && stepId) {
// Emit signature so it can be replayed in multi-turn context.
// It belongs to the reasoning message, not the step, so an AG-UI
// client finds that message by `entityId`.
if (accumulatedSignature && stepId && reasoningMessageId) {
yield {
type: EventType.STEP_FINISHED,
stepName: stepId,
Expand All @@ -1342,7 +1412,14 @@ export class AnthropicTextAdapter<
timestamp: Date.now(),
delta: '',
content: accumulatedThinking,
signature: accumulatedSignature,
}
yield {
type: EventType.REASONING_ENCRYPTED_VALUE,
subtype: 'message' as const,
entityId: reasoningMessageId,
encryptedValue: accumulatedSignature,
model,
timestamp: Date.now(),
}
}
} else if (currentBlockType === 'tool_use') {
Expand Down
Loading
Loading