Compare commits

...
Author SHA1 Message Date
GuilhemandClaude Opus 4.8 b85a5b15d4 docs: add HANDOFF.md for continuing the AI-chat thinking-blocks fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:17:56 +02:00
GuilhemandClaude Opus 4.8 c1b8297cf9 fix(ai-chat): replay assistant turns verbatim so thinking blocks validate
The global AI chat reconstructs each assistant turn from an OpenAI-shaped
message, keeping only the thinking/redacted_thinking blocks and re-injecting
them at the front of the content array. When a turn interleaves thinking with
the native web_search tool and ends in a tool call, this reorders the thinking
blocks and drops the server_tool_use / web_search_tool_result blocks. Anthropic
validates each thinking block's signature against the blocks that precede it in
the latest assistant message, so the replayed turn is rejected:

  400 invalid_request_error
  "messages.N.content.M: `thinking` or `redacted_thinking` blocks in the latest
   assistant message cannot be modified. These blocks must remain as they were
   in the original response."

Preserve the full `finalMessage.content` verbatim (`_anthropicContent`) and
re-emit it unchanged, instead of extracting and reordering thinking blocks. Skip
the standalone text message that the streamer emits for the same turn (its text
is already inside `_anthropicContent`). The previous thinking-only path is kept
as a fallback for sessions persisted before this change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:17:56 +02:00
3 changed files with 223 additions and 12 deletions
+60
View File
@@ -0,0 +1,60 @@
# Handoff: AI-chat thinking-blocks fix (PR #9841)
Branch: `glm/fix-copilot-thinking-blocks` · base `main` · file touched: `frontend/src/lib/components/copilot/chat/anthropic.ts`
## The bug
Global AI chat (`/sessions`, dev-gated) 400s when an assistant turn uses the native
`web_search` tool while thinking is on AND ends in a tool call:
```
400 invalid_request_error
messages.N.content.M: `thinking` or `redacted_thinking` blocks in the latest assistant
message cannot be modified. These blocks must remain as they were in the original response.
```
Root cause: the turn was reconstructed keeping only thinking blocks, reordering them to the
front and dropping the `server_tool_use` / `web_search_tool_result` blocks. With interleaved
thinking (a 2nd thinking block after the web-search result) the 2nd block's signature no
longer validates.
## What's already done (1 commit)
`frontend/src/lib/components/copilot/chat/anthropic.ts`:
- `parseAnthropicCompletion`: store the full turn as `_anthropicContent = finalMessage.content`
(replaces the thinking-only `_anthropicThinkingBlocks` extraction).
- `convertOpenAIToAnthropicMessages`: if `_anthropicContent` is present, emit it **verbatim**;
skip the redundant standalone text message for that turn; the old `_anthropicThinkingBlocks`
path is kept as a fallback for sessions persisted before this change.
- `npm run check:fast` passes. Do NOT commit `frontend/package-lock.json` churn (revert if the
install/check rewrites it).
## Verify (still TODO)
1. Run this worktree's frontend against the EE backend: from `frontend/`,
`REMOTE=http://localhost:8000 npm run dev`; open it in a browser.
2. Unlock global chat: DevTools console -> `localStorage.setItem('wm_dev_global_ai','1')` -> reload.
3. Workspace **demo-orange** -> AI Sessions -> new session (Claude model, effort `high`).
4. Prompt: "Search the web for the current NIST SP 800-63B minimum password length. After you
get the number, in a follow-up step list the resources in my workspace. Think step by step
between each tool call."
5. Expected: Thinking -> web search -> Thinking -> list resources, and the auto-continuation
no longer 400s (on `main` it does). Capture the body to
`/api/w/demo-orange/ai/proxy/v1/messages`: the replayed assistant content should keep
`thinking`, `server_tool_use`, `web_search_tool_result` in original order (not
`["thinking","thinking","tool_use"]`).
Only NEW sessions are fixed — sessions already persisted in the old shape will still fail on
continue; start a fresh session to test.
## Remaining tasks
- Confirm the repro is fixed in-app; attach before/after screenshots to PR #9841.
- Add a unit test for `convertOpenAIToAnthropicMessages` (near `AIChatManager.test.ts` /
`global/core.test.ts`): a turn with `_anthropicContent =
[thinking, server_tool_use, web_search_tool_result, thinking, tool_use]` round-trips verbatim,
and the preceding standalone text message is skipped.
- Sanity-check a plain text turn (no tools) and an old-style session (only
`_anthropicThinkingBlocks`) still convert via the fallback.
- `npm run check` (full) before marking PR ready.
- Remove this HANDOFF.md before the PR is marked ready for review.
## Deploying to orange-dev preview
Frontend-only fix — cherry-pick the commit onto the preview's frontend branch and rebuild the
frontend bundle (no backend/DB change). Already-broken sessions stay broken; verify in a new chat.
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
import { convertOpenAIToAnthropicMessages } from './anthropic'
// anthropic.ts pulls in the chat client/registry layer at import time; the
// converter under test is pure, so stub those side-effecting modules away.
vi.mock('../lib', () => ({
getProviderAndCompletionConfig: vi.fn(),
workspaceAIClients: {}
}))
vi.mock('../reasoningRegistry', () => ({
applyReasoningToConfig: vi.fn()
}))
vi.mock('./shared', () => ({
processToolCall: vi.fn()
}))
describe('convertOpenAIToAnthropicMessages', () => {
it('replays a captured assistant turn verbatim and skips the standalone text copy', () => {
const anthropicContent = [
{ type: 'thinking', thinking: 'first', signature: 'sig-1' },
{
type: 'server_tool_use',
id: 'srv_1',
name: 'web_search',
input: { query: 'nist password length' }
},
{
type: 'web_search_tool_result',
tool_use_id: 'srv_1',
content: [{ type: 'web_search_result', title: 'NIST', url: 'https://nist.gov' }]
},
{ type: 'thinking', thinking: 'second', signature: 'sig-2' },
{ type: 'tool_use', id: 'tool_1', name: 'list_resources', input: {} }
]
const messages: ChatCompletionMessageParam[] = [
{ role: 'user', content: 'find the nist length then list resources' },
// Standalone text the streamer emits before the tool-call message.
{ role: 'assistant', content: 'Let me search the web.' },
{
role: 'assistant',
tool_calls: [
{
id: 'tool_1',
type: 'function',
function: { name: 'list_resources', arguments: '{}' }
}
],
_anthropicContent: anthropicContent
} as any
]
const { messages: out } = convertOpenAIToAnthropicMessages(messages)
// user + the verbatim assistant turn only — the standalone text is dropped.
expect(out).toHaveLength(2)
expect(out[0]).toEqual({ role: 'user', content: 'find the nist length then list resources' })
expect(out[1].role).toBe('assistant')
// Content replayed in original order, no reordering or dropped blocks.
expect((out[1].content as any[]).map((b) => b.type)).toEqual([
'thinking',
'server_tool_use',
'web_search_tool_result',
'thinking',
'tool_use'
])
expect(out[1].content).toEqual(anthropicContent)
})
it('converts a plain text assistant turn (no tools) normally', () => {
const messages: ChatCompletionMessageParam[] = [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'hi there' }
]
const { messages: out } = convertOpenAIToAnthropicMessages(messages)
expect(out).toHaveLength(2)
expect(out[0]).toEqual({ role: 'user', content: 'hello' })
// Last message gets cache_control and is normalized to block form.
expect(out[1].role).toBe('assistant')
expect(out[1].content).toEqual([
{ type: 'text', text: 'hi there', cache_control: { type: 'ephemeral' } }
])
})
it('falls back to _anthropicThinkingBlocks for turns persisted before _anthropicContent', () => {
const thinkingBlocks = [{ type: 'thinking', thinking: 'reasoning', signature: 'sig-old' }]
const messages: ChatCompletionMessageParam[] = [
{ role: 'user', content: 'do a thing' },
{
role: 'assistant',
tool_calls: [
{
id: 'tool_old',
type: 'function',
function: { name: 'list_resources', arguments: '{}' }
}
],
_anthropicThinkingBlocks: thinkingBlocks
} as any
]
const { messages: out } = convertOpenAIToAnthropicMessages(messages)
expect(out).toHaveLength(2)
const content = out[1].content as any[]
// Thinking block re-injected first, then the tool_use.
expect(content.map((b) => b.type)).toEqual(['thinking', 'tool_use'])
expect(content[0]).toEqual(thinkingBlocks[0])
expect(content[1]).toMatchObject({ type: 'tool_use', id: 'tool_old', name: 'list_resources' })
})
})
@@ -286,15 +286,15 @@ export async function parseAnthropicCompletion(
role: 'assistant',
tool_calls: toolCallsToProcess
}
// Preserve thinking blocks (with signatures) so the next request keeps the
// reasoning chain — Anthropic requires this when thinking is combined with tool
// use. They are re-injected by convertOpenAIToAnthropicMessages.
const thinkingBlocks = finalMessage.content.filter(
(b) => b.type === 'thinking' || b.type === 'redacted_thinking'
)
if (thinkingBlocks.length > 0) {
;(assistantWithTools as any)._anthropicThinkingBlocks = thinkingBlocks
}
// Preserve the full assistant turn verbatim (thinking/redacted_thinking blocks
// with their signatures, server_tool_use + web_search_tool_result, text and
// tool_use) in original order. Anthropic validates each thinking block's
// signature against the blocks that precede it in the latest assistant message,
// so the turn must be replayed exactly as received: reordering the thinking
// blocks or dropping the web-search blocks makes a signature no longer match and
// the request is rejected with "thinking blocks ... cannot be modified".
// convertOpenAIToAnthropicMessages re-emits this content verbatim.
;(assistantWithTools as any)._anthropicContent = finalMessage.content
messages.push(assistantWithTools)
addedMessages.push(assistantWithTools)
@@ -323,7 +323,31 @@ export function convertOpenAIToAnthropicMessages(messages: ChatCompletionMessage
let system: TextBlockParam[] | undefined
const anthropicMessages: MessageParam[] = []
for (const message of messages) {
// A streamed assistant turn that ends in tool calls is stored as a standalone text
// message followed by the tool-call message carrying _anthropicContent. That text is
// already part of _anthropicContent (replayed verbatim below), so skip the standalone
// copy — otherwise it is duplicated and emitted ahead of the turn's thinking blocks.
const skipStandaloneText = new Set<number>()
for (let i = 0; i < messages.length; i++) {
if (!(messages[i] as any)._anthropicContent) continue
for (let j = i - 1; j >= 0; j--) {
const m = messages[j]
if (
m.role === 'assistant' &&
typeof m.content === 'string' &&
!m.tool_calls &&
!(m as any)._anthropicContent
) {
skipStandaloneText.add(j)
} else {
break
}
}
}
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (skipStandaloneText.has(i)) continue
if (message.role === 'system') {
const systemText =
typeof message.content === 'string' ? message.content : JSON.stringify(message.content)
@@ -345,10 +369,20 @@ export function convertOpenAIToAnthropicMessages(messages: ChatCompletionMessage
typeof message.content === 'string' ? message.content : JSON.stringify(message.content)
})
} else if (message.role === 'assistant') {
// Replay the original assistant turn verbatim when it was captured (see the
// _anthropicContent note where the streamed turn is stored) so thinking-block
// signatures stay valid.
const anthropicContent = (message as any)._anthropicContent
if (Array.isArray(anthropicContent) && anthropicContent.length > 0) {
anthropicMessages.push({ role: 'assistant', content: anthropicContent as any })
continue
}
const content: any[] = []
// Re-inject preserved thinking blocks first (Anthropic requires thinking to
// precede tool_use in the same assistant turn when thinking is enabled).
// Fallback for turns persisted before _anthropicContent existed: re-inject the
// preserved thinking blocks first (Anthropic requires thinking to precede
// tool_use in the same assistant turn when thinking is enabled).
const thinkingBlocks = (message as any)._anthropicThinkingBlocks
if (Array.isArray(thinkingBlocks) && thinkingBlocks.length > 0) {
content.push(...thinkingBlocks)