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
37 changes: 36 additions & 1 deletion packages/typescript/ai-anthropic/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,9 @@ export class AnthropicTextAdapter<
system: options.systemPrompts?.join('\n'),
tools: tools,
...validProviderOptions,
...(thinkingBudget && {
betas: ['interleaved-thinking-2025-05-14'] as any,
}),
}
validateTextProviderOptions(requestParams)
return requestParams
Expand Down Expand Up @@ -389,6 +392,18 @@ export class AnthropicTextAdapter<
if (role === 'assistant' && message.toolCalls?.length) {
const contentBlocks: AnthropicContentBlocks = []

if (message.thinking?.length) {
for (const thinking of message.thinking) {
if (thinking.signature) {
contentBlocks.push({
type: 'thinking',
thinking: thinking.content,
signature: thinking.signature,
} as unknown as AnthropicContentBlock)
}
}
}

if (message.content) {
const content =
typeof message.content === 'string' ? message.content : ''
Expand Down Expand Up @@ -528,6 +543,7 @@ export class AnthropicTextAdapter<
): AsyncIterable<StreamChunk> {
let accumulatedContent = ''
let accumulatedThinking = ''
let accumulatedSignature = ''
const timestamp = Date.now()
const toolCallsMap = new Map<
number,
Expand Down Expand Up @@ -570,6 +586,7 @@ export class AnthropicTextAdapter<
})
} else if (event.content_block.type === 'thinking') {
accumulatedThinking = ''
accumulatedSignature = ''
// Emit STEP_STARTED for thinking
stepId = genId()
yield {
Expand Down Expand Up @@ -615,6 +632,11 @@ export class AnthropicTextAdapter<
delta,
content: accumulatedThinking,
}
} else if (
(event.delta as { type: string }).type === 'signature_delta'
) {
accumulatedSignature +=
(event.delta as { signature: string }).signature || ''
} else if (event.delta.type === 'input_json_delta') {
const existing = toolCallsMap.get(currentToolIndex)
if (existing) {
Expand Down Expand Up @@ -644,7 +666,20 @@ export class AnthropicTextAdapter<
}
}
} else if (event.type === 'content_block_stop') {
if (currentBlockType === 'tool_use') {
if (currentBlockType === 'thinking') {
// Emit signature so it can be replayed in multi-turn context
if (accumulatedSignature && stepId) {
yield {
type: 'STEP_FINISHED',
stepId,
model,
timestamp,
delta: '',
content: accumulatedThinking,
signature: accumulatedSignature,
}
}
} else if (currentBlockType === 'tool_use') {
const existing = toolCallsMap.get(currentToolIndex)
if (existing) {
// If tool call wasn't started yet (no args), start it now
Expand Down
6 changes: 5 additions & 1 deletion packages/typescript/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ export class ChatClient {
this.events.textUpdated(this.currentStreamId, messageId, content)
}
},
onThinkingUpdate: (messageId: string, content: string) => {
onThinkingUpdate: (
messageId: string,
_stepId: string,
content: string,
) => {
// Emit thinking update to devtools
if (this.currentStreamId) {
this.events.thinkingUpdated(
Expand Down
43 changes: 40 additions & 3 deletions packages/typescript/ai/src/activities/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ class TextEngine<
private totalChunkCount = 0
private currentMessageId: string | null = null
private accumulatedContent = ''
private accumulatedThinking: Array<{ content: string; signature?: string }> =
[]
private currentThinkingContent = ''
private currentThinkingSignature = ''
private eventOptions?: Record<string, unknown>
private eventToolNames?: Array<string>
private finishedEvent: RunFinishedEvent | null = null
Expand Down Expand Up @@ -494,6 +498,9 @@ class TextEngine<
private async beginIteration(): Promise<void> {
this.currentMessageId = this.createId('msg')
this.accumulatedContent = ''
this.accumulatedThinking = []
this.currentThinkingContent = ''
this.currentThinkingSignature = ''
this.finishedEvent = null

// Update mutable context fields
Expand Down Expand Up @@ -585,12 +592,15 @@ class TextEngine<
case 'RUN_ERROR':
this.handleRunErrorEvent(chunk)
break
case 'STEP_STARTED':
this.handleStepStartedEvent()
break
case 'STEP_FINISHED':
this.handleStepFinishedEvent(chunk)
break

default:
// RUN_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_END, STEP_STARTED,
// RUN_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_END,
// STATE_SNAPSHOT, STATE_DELTA, CUSTOM
// - no special handling needed in chat activity
break
Expand Down Expand Up @@ -633,10 +643,32 @@ class TextEngine<
this.earlyTermination = true
}

private finalizeCurrentThinkingStep(): void {
if (this.currentThinkingContent) {
this.accumulatedThinking.push({
content: this.currentThinkingContent,
...(this.currentThinkingSignature && {
signature: this.currentThinkingSignature,
}),
})
this.currentThinkingContent = ''
this.currentThinkingSignature = ''
}
}

private handleStepStartedEvent(): void {
this.finalizeCurrentThinkingStep()
}

private handleStepFinishedEvent(
_chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
): void {
// State tracking for STEP_FINISHED is handled by middleware
if (chunk.delta) {
this.currentThinkingContent += chunk.delta
}
if (chunk.signature) {
this.currentThinkingSignature = chunk.signature
}
}

private async *checkForPendingToolCalls(): AsyncGenerator<
Expand Down Expand Up @@ -939,12 +971,17 @@ class TextEngine<
}

private addAssistantToolCallMessage(toolCalls: Array<ToolCall>): void {
this.finalizeCurrentThinkingStep()

this.messages = [
...this.messages,
{
role: 'assistant',
content: this.accumulatedContent || null,
toolCalls,
...(this.accumulatedThinking.length > 0 && {
thinking: this.accumulatedThinking,
}),
},
]
}
Expand Down
13 changes: 12 additions & 1 deletion packages/typescript/ai/src/activities/chat/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ function isToolCallIncluded(part: ToolCallPart): boolean {
function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
const messageList: Array<ModelMessage> = []
let current = createSegment()
let pendingThinking: Array<{ content: string; signature?: string }> = []

Comment on lines +168 to 169
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This still loses thinking/tool-call order within a single turn.

pendingThinking is buffered independently from current.toolCalls, so a sequence like [thinking(step-1), tool-call(a), thinking(step-2), tool-call(b)] gets flattened into one assistant ModelMessage with thinking: [step-1, step-2] and toolCalls: [a, b]. If a provider emits multiple thinking blocks around multiple tool calls in one turn, the original interleaving is gone and the next request cannot faithfully replay that history.

Also applies to: 233-239

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/src/activities/chat/messages.ts` around lines 168 -
169, The issue is that pendingThinking is buffered separately from
current.toolCalls, which loses the original interleaving (e.g., thinking,
tool-call, thinking) when constructing ModelMessage; change the buffering to a
single ordered stream of typed entries (e.g., an array of {type:
'thinking'|'toolCall', payload: ...}) instead of separate pendingThinking and
current.toolCalls so you can preserve insert order when emitting/serializing;
update all places that push to pendingThinking and to current.toolCalls (and the
duplicate buffering logic referenced around the 233-239 region) to push a typed
entry into the unified buffer and update the code that builds the assistant
ModelMessage to iterate this unified buffer and emit thinking/toolCall entries
in original sequence.

// Track emitted tool result IDs to avoid duplicates.
// A tool call can have BOTH an explicit tool-result part AND an output
Expand All @@ -181,7 +182,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
role: 'assistant',
content,
...(hasToolCalls && { toolCalls: current.toolCalls }),
...(pendingThinking.length > 0 && { thinking: pendingThinking }),
})
pendingThinking = []
}
current = createSegment()
}
Expand Down Expand Up @@ -227,7 +230,15 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
}
break

// thinking parts are skipped - they're UI-only
case 'thinking':
if (part.content) {
pendingThinking.push({
content: part.content,
...(part.signature && { signature: part.signature }),
})
}
break

default:
break
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,28 +244,35 @@ export function updateToolCallApprovalResponse(
}

/**
* Update or add a thinking part to a message.
* Update or add a thinking part to a message, keyed by stepId.
* Each distinct stepId produces its own ThinkingPart.
*/
export function updateThinkingPart(
messages: Array<UIMessage>,
messageId: string,
stepId: string,
content: string,
signature?: string,
): Array<UIMessage> {
return messages.map((msg) => {
if (msg.id !== messageId) {
return msg
}

const parts = [...msg.parts]
const thinkingPartIndex = parts.findIndex((p) => p.type === 'thinking')
const thinkingPartIndex = parts.findIndex(
(p) => p.type === 'thinking' && p.stepId === stepId,
)

const thinkingPart: ThinkingPart = {
type: 'thinking',
content,
stepId,
...(signature && { signature }),
}

if (thinkingPartIndex >= 0) {
// Update existing thinking part
// Update existing thinking part for this step
parts[thinkingPartIndex] = thinkingPart
} else {
// Add new thinking part at the end (preserve natural streaming order)
Expand Down
Loading
Loading