diff --git a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts index 119337381..6e9cab15c 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts @@ -459,6 +459,75 @@ describe('MistralAIProvider.complete non-stream output', () => { }); }); + it('flattens a magistral chunked content array into string content + reasoning', async () => { + // Mistral's reasoning models return `content` as a chunk array with + // the thinking text nested inside `thinking` chunks. Left alone it + // reaches the caller as an array with no `reasoning`, breaking the + // one-shape-per-provider guarantee. + const { provider } = makeProvider(); + completeMock.mockResolvedValueOnce({ + choices: [ + { + message: { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: [ + { type: 'text', text: 'step one.' }, + ], + }, + { + type: 'thinking', + thinking: [ + { type: 'text', text: 'step two.' }, + ], + }, + { type: 'text', text: 'the answer' }, + ], + }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 1, completionTokens: 1 }, + }); + + const result = (await withTestActor(() => + provider.complete({ + model: 'magistral-small-latest', + messages: [{ role: 'user', content: 'think' }], + }), + )) as { message: Record }; + + expect(result.message.content).toBe('the answer'); + // Multiple thinking chunks join with a blank line, matching the + // Responses handler and the Anthropic coercer. + expect(result.message.reasoning).toBe('step one.\n\nstep two.'); + }); + + it('leaves plain string content untouched', async () => { + const { provider } = makeProvider(); + completeMock.mockResolvedValueOnce({ + choices: [ + { + message: { role: 'assistant', content: 'plain' }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 1, completionTokens: 1 }, + }); + + const result = (await withTestActor(() => + provider.complete({ + model: 'mistral-small-2603', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as { message: Record }; + + expect(result.message.content).toBe('plain'); + expect('reasoning' in result.message).toBe(false); + }); + it('preserves OpenAI-shaped tool_calls on the assistant response', async () => { const { provider } = makeProvider(); completeMock.mockResolvedValueOnce({ @@ -571,6 +640,78 @@ describe('MistralAIProvider.complete streaming', () => { }); }); + it('flattens chunked delta.content into text and reasoning events', async () => { + const { provider } = makeProvider(); + streamMock.mockReturnValueOnce( + asAsyncIterable([ + { + data: { + choices: [ + { + delta: { + content: [ + { + type: 'thinking', + thinking: [ + { + type: 'text', + text: 'thinking…', + }, + ], + }, + ], + }, + }, + ], + }, + }, + { + data: { + choices: [ + { + delta: { + content: [ + { type: 'text', text: 'answer' }, + ], + }, + }, + ], + }, + }, + { + data: { + choices: [{ delta: {} }], + usage: { promptTokens: 1, completionTokens: 1 }, + }, + }, + ]), + ); + + const result = await withTestActor(() => + provider.complete({ + model: 'magistral-small-latest', + messages: [{ role: 'user', content: 'think' }], + stream: true, + }), + ); + + const harness = makeCapturingChatStream(); + await ( + result as { + init_chat_stream: (p: { chatStream: unknown }) => Promise; + } + ).init_chat_stream({ chatStream: harness.chatStream }); + + const events = harness.events(); + // The thinking chunk becomes a reasoning event, not stringified text. + expect( + events.filter((e) => e.type === 'reasoning').map((e) => e.reasoning), + ).toEqual(['thinking…']); + expect(events.filter((e) => e.type === 'text').map((e) => e.text)).toEqual( + ['answer'], + ); + }); + it('builds a tool_use block from camelCase delta.toolCalls deltas', async () => { const { provider } = makeProvider(); streamMock.mockReturnValueOnce( diff --git a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts index 756e5833e..08a29fd9e 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts @@ -30,6 +30,51 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { MISTRAL_MODELS } from './models.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +/** + * Mistral's reasoning models (`magistral-*`) return `content` as a chunk array + * rather than a string, with the thinking text nested one level deeper inside + * `thinking` chunks. Split it into the string content + `reasoning` string + * every other provider produces. Text nested in a chunk is joined; a `thinking` + * chunk's own chunks are flattened the same way. + */ +const flattenChunkText = (value: unknown): string => { + if (typeof value === 'string') return value; + if (!Array.isArray(value)) return ''; + return value + .map((chunk) => { + if (typeof chunk === 'string') return chunk; + const c = chunk as Record; + return typeof c?.text === 'string' ? c.text : ''; + }) + .join(''); +}; + +const splitMistralContentChunks = ( + content: unknown[], +): { text: string; reasoning: string } => { + const textParts: string[] = []; + const reasoningParts: string[] = []; + for (const chunk of content) { + if (typeof chunk === 'string') { + textParts.push(chunk); + continue; + } + const c = chunk as Record; + if (c?.type === 'thinking') { + const thinking = flattenChunkText(c.thinking); + if (thinking) reasoningParts.push(thinking); + continue; + } + // Non-text chunks (`reference`, images) carry nothing to surface as + // message content and are dropped, same as the Anthropic coercer. + if (typeof c?.text === 'string') textParts.push(c.text); + } + return { + text: textParts.join(''), + reasoning: reasoningParts.join('\n\n'), + }; +}; + // Mistral's finish reasons mapped to the OpenAI vocabulary; values without // an OpenAI analog (e.g. `error`) pass through unmapped. const MISTRAL_FINISH_REASON_MAP: Record = { @@ -179,6 +224,17 @@ export class MistralAIProvider implements IChatProvider { })); } if (message) delete message.toolCalls; + if (message && Array.isArray(message.content)) { + const { text, reasoning } = splitMistralContentChunks( + message.content, + ); + // Null content alongside tool calls is OpenAI's own + // convention for a tool-only turn. + message.content = text === '' ? null : text; + if (reasoning && message.reasoning === undefined) { + message.reasoning = reasoning; + } + } } } @@ -199,8 +255,32 @@ export class MistralAIProvider implements IChatProvider { return snake_usage; }, - chunk_but_like_actually: (chunk: unknown) => - (chunk as any).data, + // Mistral wraps each event; unwrap it, then flatten a + // reasoning model's chunked `delta.content` so the shared + // stream handler sees the string content + `reasoning` delta + // it expects from every other provider. + chunk_but_like_actually: (chunk: unknown) => { + const data = (chunk as { data?: unknown }).data as + | { + choices?: { + delta?: Record; + }[]; + } + | undefined; + if (!data || !Array.isArray(data.choices)) return data; + for (const choice of data.choices) { + const delta = choice?.delta; + if (!delta || !Array.isArray(delta.content)) continue; + const { text, reasoning } = splitMistralContentChunks( + delta.content, + ); + delta.content = text; + if (reasoning && delta.reasoning === undefined) { + delta.reasoning = reasoning; + } + } + return data; + }, index_tool_calls_from_stream_choice: (choice: { delta?: unknown; }) => (choice.delta as any).toolCalls, diff --git a/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts b/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts index d80f751f2..6fd2ea54f 100644 --- a/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts +++ b/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts @@ -484,6 +484,28 @@ const fixtures: Record< ], usage: { promptTokens: 3, completionTokens: 5 }, }), + // Mistral's reasoning models (magistral) return `content` as a chunk + // array, with the thinking text nested one level deeper inside + // `thinking` chunks. Without the provider's flattening this reaches + // the caller as an array with no `reasoning` at all. + reasoning: () => ({ + choices: [ + { + message: { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: [{ type: 'text', text: REASONING }], + }, + { type: 'text', text: TEXT }, + ], + }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 3, completionTokens: 5 }, + }), }, anthropic: { text: () => ({