Skip to content

Commit de94ef9

Browse files
authored
fix(api): AI chat follow-ups no longer fail on Claude models with an invalid thinking signature (#595)
With a Claude model selected, the first question in an AI chat got an answer but every follow-up failed with `Invalid signature in thinking block`. Anthropic binds each thinking block to the exact conversation that produced it, and the replayed history did not match: chat message parts were stored in a jsonb column, and Postgres jsonb reorders object keys and normalizes numbers, so tool inputs and results came back changed. Fixed - Follow-up messages in an AI chat no longer fail with a 400 from Anthropic when a Claude model is selected. Stored message parts now come back exactly as the model produced them. - Any other change to a conversation's prefix (the system prompt carries today's date and the page the chat was opened from) now makes Claude drop its stale reasoning instead of failing the turn, by sending `prefix_mismatch_behavior: drop_block` with the `thinking-binding-controls-2026-08-01` beta. Upgrade notes - A Prisma migration converts `chat_messages.parts` from jsonb to json; it runs with the usual migrations on upgrade and needs no action. Conversations started before the upgrade keep working, but Claude re-plans without their earlier reasoning. Internal - Tests: a Postgres round-trip test for stored message parts, a request-capture test for the Claude thinking options, and a dashboard e2e test for a follow-up after a tool-using answer that runs on Claude when a self-hosted API offers it. Closes #593
1 parent ed3061f commit de94ef9

6 files changed

Lines changed: 224 additions & 3 deletions

File tree

‎apps/start/e2e/chat.spec.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1+
import {
2+
type ChatModelEntry,
3+
MODEL_STORAGE_KEY,
4+
} from '@openpanel/core/modules/assistant/assistant.constants';
15
import { expect as baseExpect, test } from './fixtures';
6+
import { shot, trpcQuery } from './org-helpers';
7+
8+
/** The model list the API offers the dashboard, and whether a self-hoster may pick one. */
9+
async function chatModels(page: Parameters<typeof trpcQuery>[0]) {
10+
return trpcQuery<{
11+
models: ChatModelEntry[];
12+
canChooseModel: boolean;
13+
}>(page, 'chat.models');
14+
}
215

316
// A real model answers the question, so the budget is generous.
417
const expect = baseExpect.configure({ timeout: 60_000 });
@@ -79,4 +92,62 @@ test.describe('AI chat', () => {
7992
// The list shows the chat under "Today" with a generated title.
8093
await expect(page.getByText('Today', { exact: true })).toBeVisible();
8194
});
95+
96+
// The history of a turn that ran tools is replayed to the model on the next
97+
// turn; it has to come back from storage exactly as the model produced it.
98+
// Claude rejects a replayed turn whose stored parts differ from what it
99+
// produced, so the test runs on Claude whenever the API offers it and on the
100+
// default model otherwise (the storage round trip matters to both).
101+
test('a follow-up after a tool-using answer gets an answer', async ({
102+
page,
103+
}) => {
104+
const { models, canChooseModel } = await chatModels(page);
105+
const claude = canChooseModel
106+
? models.find((model) => model.provider === 'anthropic')
107+
: undefined;
108+
test.info().annotations.push({
109+
type: 'model',
110+
description: claude?.id ?? 'default',
111+
});
112+
if (claude) {
113+
await page.addInitScript(
114+
([key, id]) => window.localStorage.setItem(key, id),
115+
[MODEL_STORAGE_KEY, claude.id]
116+
);
117+
}
118+
await page.goto(CHAT);
119+
if (await page.getByText(NOT_CONFIGURED).isVisible()) {
120+
test.skip();
121+
return;
122+
}
123+
if (claude) {
124+
await expect(
125+
page.getByRole('button', { name: 'Select model' })
126+
).toHaveText(new RegExp(claude.label));
127+
}
128+
const input = page.getByRole('textbox', { name: 'Message' });
129+
const send = page.getByRole('button', { name: 'Send message' });
130+
await input.fill(
131+
'Show me the top 3 pages by views in the last 7 days as a bar chart'
132+
);
133+
await input.press('Enter');
134+
await expect(page).toHaveURL(/\/chat\?.*c=/);
135+
await expect(send).toBeVisible({ timeout: 120_000 });
136+
// The first answer must have run the chart tool, or the follow-up proves nothing.
137+
await expect(
138+
page.getByRole('button', { name: 'Expand chart' }).first()
139+
).toBeVisible();
140+
141+
await input.fill('And which countries do those views come from?');
142+
await input.press('Enter');
143+
await expect(
144+
page.getByRole('button', { name: 'Stop generating' })
145+
).toBeVisible();
146+
await expect(send).toBeVisible({ timeout: 120_000 });
147+
await shot(page, 'chat-follow-up');
148+
await expect(
149+
page.getByText(/Something went wrong|Invalid `signature`/)
150+
).toHaveCount(0);
151+
await expect(page.getByText(/countr/i).nth(1)).toBeVisible();
152+
});
82153
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { afterAll, beforeAll, expect, test } from 'bun:test';
2+
import { testServiceDeps } from '../../../../test/service-deps';
3+
import type { ServiceDeps } from '../../../services';
4+
import type { AssistantUIMessage } from './message';
5+
import { loadMessages, saveMessages } from './persistence';
6+
7+
const ORGANIZATION_ID = 'chat-persistence-org';
8+
const PROJECT_ID = 'chat-persistence-project';
9+
const USER_ID = 'chat-persistence-user';
10+
const CONVERSATION_ID = 'chat-persistence-conversation';
11+
12+
let deps: ServiceDeps;
13+
14+
beforeAll(async () => {
15+
deps = await testServiceDeps();
16+
await deps.db.organization.upsert({
17+
where: { id: ORGANIZATION_ID },
18+
create: { id: ORGANIZATION_ID, name: 'Chat org', timezone: 'UTC' },
19+
update: {},
20+
});
21+
await deps.db.project.upsert({
22+
where: { id: PROJECT_ID },
23+
create: { id: PROJECT_ID, name: 'Chat', organizationId: ORGANIZATION_ID },
24+
update: {},
25+
});
26+
await deps.db.user.upsert({
27+
where: { id: USER_ID },
28+
create: { id: USER_ID, email: 'chat-persistence@openpanel.test' },
29+
update: {},
30+
});
31+
});
32+
33+
afterAll(async () => {
34+
await deps.db.conversation.deleteMany({ where: { id: CONVERSATION_ID } });
35+
await deps.db.project.deleteMany({ where: { id: PROJECT_ID } });
36+
await deps.db.organization.deleteMany({ where: { id: ORGANIZATION_ID } });
37+
await deps.db.user.deleteMany({ where: { id: USER_ID } });
38+
});
39+
40+
// Anthropic binds a thinking block to the bytes of the conversation before
41+
// it, so a tool input or output must come back exactly as the model saw it.
42+
test('message parts survive storage byte for byte, including key order', async () => {
43+
const message: AssistantUIMessage = {
44+
id: 'chat-persistence-assistant',
45+
role: 'assistant',
46+
parts: [
47+
{ type: 'step-start' },
48+
{
49+
type: 'reasoning',
50+
text: 'Look at pages first.',
51+
providerMetadata: { anthropic: { signature: 'sig-1' } },
52+
},
53+
{
54+
type: 'tool-get_top_pages',
55+
toolCallId: 'call-1',
56+
state: 'output-available',
57+
input: { startDate: '2026-10-01', endDate: '2026-10-23', limit: 5 },
58+
output: {
59+
rows: [{ views: 12, path: '/pricing' }],
60+
longerKeyAfterShort: true,
61+
a: 1,
62+
},
63+
} as unknown as AssistantUIMessage['parts'][number],
64+
],
65+
};
66+
67+
await saveMessages(deps, {
68+
conversationId: CONVERSATION_ID,
69+
projectId: PROJECT_ID,
70+
organizationId: ORGANIZATION_ID,
71+
userId: USER_ID,
72+
messages: [message],
73+
});
74+
const [loaded] = await loadMessages(deps, CONVERSATION_ID);
75+
76+
expect(JSON.stringify(loaded?.parts)).toBe(JSON.stringify(message.parts));
77+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { afterAll, beforeAll, expect, test } from 'bun:test';
2+
import { streamText } from 'ai';
3+
import { testCoreConfig } from '../../../../test/config-fixture';
4+
import { CHAT_MODELS } from '../assistant.constants';
5+
import { reasoningProviderOptions, resolveChatModel } from './providers';
6+
7+
const NOT_FOUND = 404;
8+
9+
let server: ReturnType<typeof Bun.serve>;
10+
let captured: { body: Record<string, unknown>; headers: Headers } | null = null;
11+
12+
beforeAll(() => {
13+
server = Bun.serve({
14+
port: 0,
15+
fetch: async (request) => {
16+
captured = {
17+
body: (await request.json()) as Record<string, unknown>,
18+
headers: request.headers,
19+
};
20+
return new Response('', { status: NOT_FOUND });
21+
},
22+
});
23+
});
24+
25+
afterAll(() => {
26+
server.stop(true);
27+
});
28+
29+
// A thinking block replayed after any change to the conversation before it is
30+
// a 400 unless the request opts into dropping it instead.
31+
test('a Claude turn asks Anthropic to drop stale thinking blocks', async () => {
32+
const config = testCoreConfig();
33+
config.ai.anthropic.apiKey = 'test-key';
34+
config.ai.anthropic.baseUrl = server.url.origin;
35+
const entry = CHAT_MODELS.find((model) => model.provider === 'anthropic');
36+
if (!entry) {
37+
throw new Error('no Anthropic chat model in the catalog');
38+
}
39+
40+
const result = streamText({
41+
model: resolveChatModel(config, entry),
42+
prompt: 'hi',
43+
providerOptions: reasoningProviderOptions(entry),
44+
onError: () => undefined,
45+
});
46+
// The stub answers 404 on purpose; only the request matters.
47+
await result.consumeStream();
48+
49+
expect(captured?.body.thinking).toEqual({
50+
type: 'adaptive',
51+
display: 'summarized',
52+
block_binding: { prefix_mismatch_behavior: 'drop_block' },
53+
});
54+
expect(captured?.headers.get('anthropic-beta')?.split(',')).toContain(
55+
'thinking-binding-controls-2026-08-01'
56+
);
57+
});

‎packages/core/src/modules/assistant/src/providers.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,14 @@ type ProviderOptions = NonNullable<
9393
Parameters<typeof streamText>[0]['providerOptions']
9494
>;
9595

96-
/** Reasoning summaries are off by default on both providers; the chat shows them. */
96+
/**
97+
* Reasoning summaries are off by default on both providers; the chat shows them.
98+
*
99+
* Anthropic binds each thinking block to the conversation prefix that produced
100+
* it and rejects the whole request when that prefix changed (the system prompt
101+
* carries today's date and the launch hint, so it can). `drop_block` makes the
102+
* API drop the stale blocks instead of failing the turn.
103+
*/
97104
export function reasoningProviderOptions(
98105
entry: ChatModelEntry
99106
): ProviderOptions {
@@ -102,7 +109,11 @@ export function reasoningProviderOptions(
102109
}
103110
return {
104111
anthropic: {
105-
thinking: { type: 'adaptive' as const, display: 'summarized' as const },
112+
thinking: {
113+
type: 'adaptive' as const,
114+
display: 'summarized' as const,
115+
blockBinding: { prefixMismatchBehavior: 'drop_block' as const },
116+
},
106117
},
107118
};
108119
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- jsonb reorders object keys and normalizes numbers, so a replayed turn no
2+
-- longer matched what the model saw and Anthropic rejected its thinking
3+
-- signatures. json stores the parts verbatim.
4+
ALTER TABLE "chat_messages" ALTER COLUMN "parts" TYPE JSON USING "parts"::text::json;

‎packages/db/prisma/schema.prisma‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ model ChatMessage {
5757
conversationId String
5858
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
5959
role String
60+
// json, not jsonb: the model replays these verbatim and jsonb reorders keys.
6061
/// [IPrismaUIMessageParts]
61-
parts Json
62+
parts Json @db.Json
6263
/// [IPrismaChatMessageMetadata]
6364
metadata Json?
6465
createdAt DateTime @default(now())

0 commit comments

Comments
 (0)