From 40d081b5ebc522c912fa677f81f56f4f9fa2e8d8 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 23 Aug 2026 23:23:57 +0800 Subject: [PATCH 1/4] fix: surface and render provider reasoning summaries Generated-by: Codex --- .../src/main/e2e-fixture/scenarios-chat.ts | 9 +- docs/astryx-surface-file-inventory.md | 1 + docs/astryx-surface-file-inventory.paths | 1 + package-lock.json | 1 + .../claude-subscription-runtime.test.ts | 8 +- .../__tests__/model-factory-thinking.test.ts | 175 +++++++++++++++--- .../openai-responses-model-adapter.test.ts | 157 ++++++++++++++++ .../__tests__/provider-conformance.test.ts | 110 +++++++++++ packages/runtime/src/model-factory.ts | 113 +++++++++-- packages/ui/package.json | 1 + .../ui/src/__tests__/markdown-body.test.ts | 33 ++++ packages/ui/src/astryx-chat-reasoning.tsx | 4 +- packages/ui/src/chat-turn.tsx | 28 ++- packages/ui/src/markdown-body.tsx | 7 +- packages/ui/src/markdown-math.tsx | 166 +++++++++++++++++ packages/ui/src/styles.css | 44 ++++- 16 files changed, 800 insertions(+), 58 deletions(-) create mode 100644 packages/ui/src/markdown-math.tsx diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index 5df48fd8f7..420ee2e516 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -86,7 +86,14 @@ export function turnMessages(now: number): StoredMessage[] { turnId, ts: now - 9 * 60_000, text: '当前需要重点观察截图基线是否稳定、启用模型名单是否清晰,以及完整目录是否只在搜索时出现。', - thinking: { text: '确认关键 UI 状态和工具执行结果。' }, + thinking: { + text: [ + '**Calculating CRT Solution**', + '', + 'First combine \\( n \\equiv 3 \\pmod 7 \\) with \\( n \\equiv 5 \\pmod {11} \\).', + 'Then solve \\( 7a \\equiv 2 \\pmod {11} \\), giving \\( a \\equiv 5 \\pmod {11} \\).', + ].join('\n'), + }, modelId: 'glm-5.1', }, { diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 5d8510fa4a..50c52e4acf 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -206,6 +206,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/locale-context.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/maka-wordmark.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/markdown-body.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `packages/ui/src/markdown-math.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/markdown.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/mermaid-diagram.tsx` | ui-composition | Button, Collapsible, Dialog, IconButton, Toolbar | aligned — uses Astryx (Button, Collapsible, Dialog, IconButton, Toolbar) | aligned | | `packages/ui/src/model-picker.tsx` | ui-composition | Selector | aligned — uses Astryx (Selector) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index bf618b944e..60e42249bf 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -178,6 +178,7 @@ packages/ui/src/inline-rename-input.tsx packages/ui/src/locale-context.tsx packages/ui/src/maka-wordmark.tsx packages/ui/src/markdown-body.tsx +packages/ui/src/markdown-math.tsx packages/ui/src/markdown.tsx packages/ui/src/mermaid-diagram.tsx packages/ui/src/model-picker.tsx diff --git a/package-lock.json b/package-lock.json index 262c8d4ef9..7348cd358a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14450,6 +14450,7 @@ "@astryxdesign/core": "0.5.0", "@maka/core": "0.1.0", "clsx": "^2.1.1", + "katex": "0.16.47", "lucide-react": "^1.33.0", "mermaid": "^11.17.0", "react": "^19.2.8", diff --git a/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts b/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts index 51007ad98e..fc2c9e9c70 100644 --- a/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts +++ b/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts @@ -129,7 +129,13 @@ describe('Claude subscription runtime wiring', () => { test('Codex OAuth provider options use non-persistent ChatGPT backend defaults', () => { assert.deepEqual(buildProviderOptions(codexOAuthConnection(), 'gpt-5.5'), { - openai: { store: false, textVerbosity: 'medium', parallelToolCalls: true }, + openai: { + store: false, + textVerbosity: 'medium', + reasoningSummary: 'auto', + reasoningEffort: 'medium', + parallelToolCalls: true, + }, }); }); }); diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 57b9dbe886..125af1198e 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -50,17 +50,41 @@ describe('buildProviderOptions: thinking level', () => { test('anthropic effort model (opus-4-8) sends effort field directly; no budgetTokens mapping', () => { assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-opus-4-8', 'high'), { - anthropic: { cacheControl: { type: 'ephemeral' }, effort: 'high' }, + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'adaptive', display: 'summarized' }, + effort: 'high', + }, }); assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-opus-4-8', 'max'), { - anthropic: { cacheControl: { type: 'ephemeral' }, effort: 'max' }, + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'adaptive', display: 'summarized' }, + effort: 'max', + }, }); assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-opus-4-8', 'xhigh'), { - anthropic: { cacheControl: { type: 'ephemeral' }, effort: 'xhigh' }, + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'adaptive', display: 'summarized' }, + effort: 'xhigh', + }, + }); + assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-opus-4-8'), { + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'adaptive', display: 'summarized' }, + }, }); }); test('anthropic budget/toggle model (haiku-4-5) sends thinking.disabled for off; drops unsupported effort', () => { + assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-haiku-4-5'), { + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'enabled', budgetTokens: 1_024 }, + }, + }); assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-haiku-4-5', 'off'), { anthropic: { cacheControl: { type: 'ephemeral' }, @@ -73,6 +97,28 @@ describe('buildProviderOptions: thinking level', () => { }); }); + test('Claude 4.5 uses legacy enabled thinking even when the UI exposes effort levels', () => { + assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-opus-4-5'), { + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'enabled', budgetTokens: 1_024 }, + }, + }); + assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-opus-4-5', 'high'), { + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'enabled', budgetTokens: 1_024 }, + effort: 'high', + }, + }); + assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-sonnet-4-5'), { + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'enabled', budgetTokens: 1_024 }, + }, + }); + }); + test('anthropic effort model without toggle (opus-4-8) drops off (cannot disable)', () => { assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-opus-4-8', 'off'), { anthropic: { cacheControl: { type: 'ephemeral' } }, @@ -124,11 +170,29 @@ describe('buildProviderOptions: thinking level', () => { assert.deepEqual(buildProviderOptions(conn('openai'), 'gpt-4o', 'high'), { openai: { store: false, parallelToolCalls: true }, }); + assert.deepEqual(buildProviderOptions(conn('openai'), 'gpt-5.5'), { + openai: { + store: false, + reasoningSummary: 'auto', + reasoningEffort: 'medium', + parallelToolCalls: true, + }, + }); assert.deepEqual(buildProviderOptions(conn('openai'), 'gpt-5.5', 'medium'), { - openai: { store: false, reasoningEffort: 'medium', parallelToolCalls: true }, + openai: { + store: false, + reasoningSummary: 'auto', + reasoningEffort: 'medium', + parallelToolCalls: true, + }, }); assert.deepEqual(buildProviderOptions(conn('openai'), 'gpt-5.5', 'xhigh'), { - openai: { store: false, reasoningEffort: 'xhigh', parallelToolCalls: true }, + openai: { + store: false, + reasoningSummary: 'auto', + reasoningEffort: 'xhigh', + parallelToolCalls: true, + }, }); assert.deepEqual(buildProviderOptions(conn('openai'), 'gpt-5.5', 'off'), { openai: { store: false, reasoningEffort: 'none', parallelToolCalls: true }, @@ -137,12 +201,19 @@ describe('buildProviderOptions: thinking level', () => { test('openai-codex (gpt-5.5) preserves store:false / textVerbosity and merges reasoningEffort', () => { assert.deepEqual(buildProviderOptions(conn('openai-codex'), 'gpt-5.5'), { - openai: { store: false, textVerbosity: 'medium', parallelToolCalls: true }, + openai: { + store: false, + textVerbosity: 'medium', + reasoningSummary: 'auto', + reasoningEffort: 'medium', + parallelToolCalls: true, + }, }); assert.deepEqual(buildProviderOptions(conn('openai-codex'), 'gpt-5.5', 'high'), { openai: { store: false, textVerbosity: 'medium', + reasoningSummary: 'auto', reasoningEffort: 'high', parallelToolCalls: true, }, @@ -163,7 +234,12 @@ describe('buildProviderOptions: thinking level', () => { models: [{ id: 'gpt-5.5', capabilities: { parallelToolCalls: false } }], }; assert.deepEqual(buildProviderOptions(disabled, 'gpt-5.5'), { - openai: { store: false, parallelToolCalls: false }, + openai: { + store: false, + reasoningSummary: 'auto', + reasoningEffort: 'medium', + parallelToolCalls: false, + }, }); const compatible: LlmConnection = { @@ -294,6 +370,9 @@ describe('buildProviderOptions: thinking level', () => { assert.deepEqual(buildProviderOptions(conn('openrouter'), 'openai/gpt-5.6-sol', 'off'), { openrouter: { reasoningEffort: 'none' }, }); + assert.deepEqual(buildProviderOptions(conn('openrouter'), 'openai/gpt-5.6-sol'), { + openrouter: { reasoningEffort: 'medium' }, + }); // claude-sonnet-5 exposes no off switch (no `none` effort); only effort tiers. assert.deepEqual( [...thinkingVariantsForModel('openrouter', 'anthropic/claude-sonnet-5')], @@ -356,12 +435,16 @@ describe('buildProviderOptions: thinking level', () => { openai: { store: false, forceReasoning: true, + reasoningSummary: 'auto', reasoningEffort: 'high', parallelToolCalls: true, }, }); assert.deepEqual(buildProviderOptions(conn('opencode'), 'claude-fable-5', 'high'), { - anthropic: { effort: 'high' }, + anthropic: { + thinking: { type: 'adaptive', display: 'summarized' }, + effort: 'high', + }, }); assert.deepEqual(buildProviderOptions(conn('opencode'), 'gemini-3.5-flash', 'high'), { google: { thinkingConfig: { includeThoughts: true, thinkingLevel: 'high' } }, @@ -379,10 +462,21 @@ describe('buildProviderOptions: thinking level', () => { test('github-copilot routes thinking by the account-declared model protocol', () => { const anthropic = { ...conn('github-copilot'), - models: [{ id: 'claude-opus-4.8', apiProtocol: 'anthropic-messages' as const }], + models: [{ id: 'anthropic/claude-opus-4-8', apiProtocol: 'anthropic-messages' as const }], + }; + assert.deepEqual(buildProviderOptions(anthropic, 'anthropic/claude-opus-4-8'), { + anthropic: { + thinking: { type: 'adaptive', display: 'summarized' }, + }, + }); + const legacyAnthropic = { + ...conn('github-copilot'), + models: [{ id: 'anthropic/claude-opus-4.5', apiProtocol: 'anthropic-messages' as const }], }; - assert.deepEqual(buildProviderOptions(anthropic, 'claude-opus-4.8', 'high'), { - anthropic: { effort: 'high' }, + assert.deepEqual(buildProviderOptions(legacyAnthropic, 'anthropic/claude-opus-4.5'), { + anthropic: { + thinking: { type: 'enabled', budgetTokens: 1_024 }, + }, }); const responses = { ...conn('github-copilot'), @@ -391,10 +485,42 @@ describe('buildProviderOptions: thinking level', () => { // The Responses protocol takes the shared wire branch, so Copilot asks for // encrypted reasoning on the same terms every other Responses model does. assert.deepEqual(buildProviderOptions(responses, 'gpt-5.5', 'high'), { - openai: { store: false, forceReasoning: true, reasoningEffort: 'high' }, + openai: { + store: false, + forceReasoning: true, + reasoningSummary: 'auto', + reasoningEffort: 'high', + }, + }); + assert.deepEqual(buildProviderOptions(responses, 'gpt-5.5'), { + openai: { + store: false, + forceReasoning: true, + reasoningSummary: 'auto', + reasoningEffort: 'medium', + }, }); }); + test('custom relays apply family defaults only when no explicit level was supplied', () => { + const openaiRelay = conn('openai-compatible', 'my-relay'); + assert.deepEqual(buildProviderOptions(openaiRelay, 'gpt-5.6-sol'), { + myRelay: { reasoningEffort: 'medium' }, + }); + assert.deepEqual(buildProviderOptions(openaiRelay, 'gpt-5.6-sol', 'minimal'), {}); + + assert.deepEqual(buildProviderOptions(conn('anthropic-compatible'), 'claude-opus-4-8'), { + anthropic: { + thinking: { type: 'adaptive', display: 'summarized' }, + }, + }); + assert.deepEqual( + buildProviderOptions(conn('anthropic-compatible'), 'claude-opus-4-8', 'off'), + {}, + ); + assert.deepEqual(buildProviderOptions(conn('anthropic-compatible'), 'minimax-m2'), {}); + }); + test('Cloudflare Workers AI sends Kimi K2.6 reasoning effort and its real thinking-off wire', () => { const modelId = '@cf/moonshotai/kimi-k2.6'; assert.deepEqual( @@ -717,17 +843,17 @@ describe('buildProviderOptions: openai-compatible namespace', () => { test('Fast provider options mirror the pinned OpenAI SDK model gate', () => { const cases = [ - ['gpt-4o', true], - ['gpt-4.1', true], - ['gpt-5', true], - ['gpt-5.1', true], - ['gpt-5-nano', false], - ['gpt-5-chat-latest', false], - ['o3-mini', true], - ['o4-mini', true], - ['plain-relay-id', false], + ['gpt-4o', true, false], + ['gpt-4.1', true, false], + ['gpt-5', true, true], + ['gpt-5.1', true, true], + ['gpt-5-nano', false, true], + ['gpt-5-chat-latest', false, false], + ['o3-mini', true, true], + ['o4-mini', true, true], + ['plain-relay-id', false, false], ] as const; - for (const [modelId, supported] of cases) { + for (const [modelId, supportsFast, supportsReasoningSummary] of cases) { const connection: LlmConnection = { ...conn('openai-responses-compatible', 'my-responses-relay'), baseUrl: 'https://relay.example/v1', @@ -738,7 +864,10 @@ describe('buildProviderOptions: openai-compatible namespace', () => { openai: { store: false, forceReasoning: true, - ...(supported ? { serviceTier: 'fast' } : {}), + ...(supportsReasoningSummary + ? { reasoningSummary: 'auto', reasoningEffort: 'medium' } + : {}), + ...(supportsFast ? { serviceTier: 'fast' } : {}), parallelToolCalls: true, }, }); diff --git a/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts b/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts index c962232372..36052d5dd5 100644 --- a/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-model-adapter.test.ts @@ -28,6 +28,7 @@ import { convertArrayToReadableStream, MockLanguageModelV4 } from 'ai/test'; import { z } from 'zod'; import { ModelAdapter } from '../model-adapter.js'; +import { buildProviderOptions, getAIModel } from '../model-factory.js'; import type { ModelMessage } from '../model-protocol.js'; import type { OpenAiResponsesSemanticBaseline } from '../openai-responses-continuation.js'; import type { OpenAiResponsesTransportState } from '../openai-responses-websocket.js'; @@ -38,6 +39,55 @@ const ZERO_USAGE: LanguageModelV4Usage = { }; describe('OpenAI Responses ModelAdapter continuation', () => { + test('maps streamed reasoning summaries into Maka thinking events', async () => { + let requestBody: Record | undefined; + const summary = 'Inspect the failing path before changing code.'; + const answer = 'The path is verified.'; + const fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) as Record; + return new Response(openAiReasoningSummaryStream(summary, answer), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }) as typeof globalThis.fetch; + const connection = { + slug: 'responses-relay', + providerType: 'openai-responses-compatible' as const, + baseUrl: 'https://relay.example/v1', + defaultModel: 'gpt-5.6-sol', + }; + const adapter = new ModelAdapter({ + sessionId: 'session-1', + connection, + apiKey: 'test-key', + modelId: 'gpt-5.6-sol', + modelFactory: (input) => getAIModel({ ...input, fetch }), + providerOptions: buildProviderOptions(connection, 'gpt-5.6-sol'), + newId: () => 'id', + now: () => 0, + }); + const model = adapter.resolveModel(); + const result = await adapter.startStream({ + model, + messages: [{ role: 'user', content: 'inspect it' }], + tools: {}, + activeTools: [], + onStreamActivity: () => {}, + abortSignal: new AbortController().signal, + repairToolCall: async () => null, + }); + let thinking = ''; + let text = ''; + for await (const event of result.events) { + if (event.kind === 'thinking') thinking += event.text; + if (event.kind === 'text') text += event.text; + } + + assert.deepEqual(requestBody?.reasoning, { effort: 'medium', summary: 'auto' }); + assert.equal(thinking, summary); + assert.equal(text, answer); + }); + test('preserves retryable WebSocket failures through the AI SDK stream boundary', async () => { const transportError = Object.assign(new Error('closed before completion'), { name: 'OpenAiResponsesTransportError', @@ -219,6 +269,113 @@ describe('OpenAI Responses ModelAdapter continuation', () => { }); }); +function openAiReasoningSummaryStream(summary: string, answer: string): string { + const reasoningId = 'rs_1'; + const messageId = 'msg_1'; + const events: Array> = [ + { + type: 'response.created', + response: { + id: 'resp-1', + object: 'response', + created_at: 0, + model: 'gpt-5.6-sol', + status: 'in_progress', + output: [], + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'reasoning', + id: reasoningId, + status: 'in_progress', + content: [], + summary: [], + }, + }, + { + type: 'response.reasoning_summary_part.added', + item_id: reasoningId, + output_index: 0, + summary_index: 0, + part: { type: 'summary_text', text: '' }, + }, + { + type: 'response.reasoning_summary_text.delta', + item_id: reasoningId, + output_index: 0, + summary_index: 0, + delta: summary, + }, + { + type: 'response.reasoning_summary_part.done', + item_id: reasoningId, + output_index: 0, + summary_index: 0, + part: { type: 'summary_text', text: summary }, + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: reasoningId, + status: 'completed', + content: [], + summary: [{ type: 'summary_text', text: summary }], + }, + }, + { + type: 'response.output_item.added', + output_index: 1, + item: { + type: 'message', + id: messageId, + status: 'in_progress', + role: 'assistant', + content: [], + }, + }, + { + type: 'response.output_text.delta', + content_index: 0, + delta: answer, + item_id: messageId, + output_index: 1, + }, + { + type: 'response.output_item.done', + output_index: 1, + item: { + type: 'message', + id: messageId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: answer, annotations: [] }], + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-1', + object: 'response', + created_at: 0, + model: 'gpt-5.6-sol', + status: 'completed', + output: [], + usage: { + input_tokens: 1, + output_tokens: 2, + output_tokens_details: { reasoning_tokens: 1 }, + }, + }, + }, + ]; + return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; +} + function firstReasoningToolStep(): LanguageModelV4StreamPart[] { const reasoningMetadata = { openai: { itemId: 'reasoning-1' } }; return [ diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 06c3cad86a..87e0d64db3 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -84,6 +84,116 @@ describe('models.dev provider conformance', () => { }); assert.deepEqual(requestBody?.cache_control, { type: 'ephemeral' }); + assert.deepEqual(requestBody?.thinking, { type: 'adaptive', display: 'summarized' }); + }); + + test('custom Anthropic relays request summarized thinking for known Claude models', async () => { + let requestBody: Record | undefined; + const server = await startJsonServer(async (request, response) => { + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/v1/messages'); + requestBody = JSON.parse(await readBody(request)) as Record; + respondJson(response, 200, { + id: 'msg_anthropic_relay', + type: 'message', + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'Relayed.' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 4, output_tokens: 1 }, + }); + }); + const connection: LlmConnection = { + slug: 'anthropic-relay', + name: 'Anthropic Relay', + providerType: 'anthropic-compatible', + baseUrl: server.url, + defaultModel: 'claude-opus-4-8', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + await generateText({ + model: getAIModel({ + connection, + apiKey: 'relay-key', + modelId: connection.defaultModel, + }), + prompt: 'Hello.', + providerOptions: buildProviderOptions(connection, connection.defaultModel), + }); + + assert.deepEqual(requestBody?.thinking, { type: 'adaptive', display: 'summarized' }); + assert.equal(requestBody?.cache_control, undefined); + }); + + test('Anthropic request bodies follow the SDK adaptive-thinking capability', async () => { + const cases = [ + { + modelId: 'claude-sonnet-4-5', + providerType: 'anthropic' as const, + expectedThinking: { type: 'enabled', budget_tokens: 1_024 }, + }, + { + modelId: 'claude-opus-4-5', + providerType: 'anthropic' as const, + thinkingLevel: 'high' as const, + expectedThinking: { type: 'enabled', budget_tokens: 1_024 }, + expectedOutputConfig: { effort: 'high' }, + }, + { + modelId: 'claude-opus-4-8', + providerType: 'anthropic' as const, + expectedThinking: { type: 'adaptive', display: 'summarized' }, + }, + { + modelId: 'anthropic/claude-opus-4.5', + providerType: 'anthropic-compatible' as const, + expectedThinking: { type: 'enabled', budget_tokens: 1_024 }, + }, + ]; + + for (const testCase of cases) { + let requestBody: Record | undefined; + const server = await startJsonServer(async (request, response) => { + requestBody = JSON.parse(await readBody(request)) as Record; + respondJson(response, 200, { + id: 'msg_anthropic_thinking_mode', + type: 'message', + role: 'assistant', + model: testCase.modelId, + content: [{ type: 'text', text: 'Done.' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 4, output_tokens: 1 }, + }); + }); + const connection: LlmConnection = { + slug: `${testCase.providerType}-${testCase.modelId}`, + name: testCase.modelId, + providerType: testCase.providerType, + baseUrl: server.url, + defaultModel: testCase.modelId, + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + await generateText({ + model: getAIModel({ + connection, + apiKey: 'test-key', + modelId: testCase.modelId, + }), + prompt: 'Hello.', + providerOptions: buildProviderOptions(connection, testCase.modelId, testCase.thinkingLevel), + }); + + assert.deepEqual(requestBody?.thinking, testCase.expectedThinking, testCase.modelId); + assert.deepEqual(requestBody?.output_config, testCase.expectedOutputConfig, testCase.modelId); + } }); test('Anthropic Messages accepts the Claude Code web_search_20250305 tool', async () => { diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 37dd1216be..1c7d0712e2 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -18,6 +18,7 @@ */ import { createAnthropic } from '@ai-sdk/anthropic'; +import { getModelCapabilities as getAnthropicModelCapabilities } from '@ai-sdk/anthropic/internal'; import { createCohere } from '@ai-sdk/cohere'; import { createGoogle } from '@ai-sdk/google'; import { createOpenResponses } from '@ai-sdk/open-responses'; @@ -374,6 +375,51 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function modelFamilyId(modelId: string): string { + return modelId.includes('/') ? modelId.slice(modelId.lastIndexOf('/') + 1) : modelId; +} + +function claudeFamilyId(modelId: string): string { + return modelFamilyId(modelId).replace( + /^(claude-(?:haiku|opus|sonnet)-\d+)\.(\d+)(?=$|-)/, + '$1-$2', + ); +} + +function defaultOpenAiReasoningEffort(modelId: string): ThinkingLevel | undefined { + const familyModelId = modelFamilyId(modelId); + return thinkingOptionsForModel('openai', familyModelId)?.efforts?.includes('medium') + ? 'medium' + : undefined; +} + +function openAiResponsesSummary(modelId: string, reasoningEffort: string | undefined) { + return reasoningEffort !== 'none' && defaultOpenAiReasoningEffort(modelId) !== undefined + ? { reasoningSummary: 'auto' as const } + : {}; +} + +function visibleClaudeThinking( + modelId: string, + thinkingOptions: ThinkingOptions | undefined, + effort: string | undefined, +) { + const familyModelId = claudeFamilyId(modelId); + if (!familyModelId.startsWith('claude-')) return undefined; + const effectiveOptions = thinkingOptions ?? thinkingOptionsForModel('anthropic', familyModelId); + const supportsThinking = + effectiveOptions?.toggle === true || (effectiveOptions?.efforts?.length ?? 0) > 0; + if (!supportsThinking) return undefined; + + const thinking = getAnthropicModelCapabilities(familyModelId).supportsAdaptiveThinking + ? { type: 'adaptive' as const, display: 'summarized' as const } + : { type: 'enabled' as const, budgetTokens: 1_024 }; + return { + thinking, + ...(effort ? { effort } : {}), + }; +} + export function buildProviderOptions( connection: RuntimeExecutionConnection, modelId: string, @@ -443,8 +489,15 @@ function buildThinkingProviderOptions( case 'MiniMax': case 'MiniMax-cn': { let reasoning = {}; + const summarizedThinking = + connection.providerType === 'anthropic' && + (thinkingLevel === undefined || level !== undefined) + ? visibleClaudeThinking(modelId, thinkingOptions, level) + : undefined; if (level === 'off' && thinkingOptions?.offBehavior === 'anthropic-thinking-disabled') { reasoning = { thinking: { type: 'disabled' as const } }; + } else if (summarizedThinking) { + reasoning = summarizedThinking; } else if (level && level !== 'off') { reasoning = { effort: level }; } @@ -457,21 +510,36 @@ function buildThinkingProviderOptions( }, }; } - case 'openai-codex': + case 'openai-codex': { + const reasoningEffort = + level === 'off' + ? 'none' + : (level ?? + (thinkingLevel === undefined ? defaultOpenAiReasoningEffort(modelId) : undefined)); return { openai: { store: false, textVerbosity: 'medium', - ...(level ? { reasoningEffort: level === 'off' ? 'none' : level } : {}), + ...openAiResponsesSummary(modelId, reasoningEffort), + ...(reasoningEffort ? { reasoningEffort } : {}), }, }; - case 'openai': + } + case 'openai': { + const usesResponses = resolveModelRuntime(connection, modelId).wire === 'openai-responses'; + const reasoningEffort = + level === 'off' + ? 'none' + : (level ?? + (thinkingLevel === undefined ? defaultOpenAiReasoningEffort(modelId) : undefined)); return { openai: { store: false, - ...(level ? { reasoningEffort: level === 'off' ? 'none' : level } : {}), + ...(usesResponses ? openAiResponsesSummary(modelId, reasoningEffort) : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), }, }; + } case 'volcengine-agent-plan': return { openai: { @@ -494,7 +562,7 @@ function buildThinkingProviderOptions( }, }; } - return buildFamilyWire(connection, modelId, level, thinkingOptions); + return buildFamilyWire(connection, modelId, level, thinkingOptions, thinkingLevel); case 'volcengine-ark': return { [toCamelCase(connection.providerType)]: { @@ -539,7 +607,7 @@ function buildThinkingProviderOptions( // above (level is defined only when metadata declares it) is what makes // this safe to generalize: undeclared models never reach the wire. default: - return buildFamilyWire(connection, modelId, level, thinkingOptions); + return buildFamilyWire(connection, modelId, level, thinkingOptions, thinkingLevel); } } @@ -593,9 +661,10 @@ function buildFamilyWire( modelId: string, level: ThinkingLevel | undefined, thinkingOptions: ThinkingOptions | undefined, + requestedLevel: ThinkingLevel | undefined, ): SharedV4ProviderOptions { const { adapter, wire, reasoningReplay } = resolveModelRuntime(connection, modelId); - const reasoningEffort = level ? (level === 'off' ? 'none' : level) : undefined; + const explicitReasoningEffort = level ? (level === 'off' ? 'none' : level) : undefined; const serviceTier = wire === 'openai-responses' && reasoningReplay.kind === 'responses' && @@ -620,38 +689,56 @@ function buildFamilyWire( // sends `xhigh` to high, not max). The SDK resolves providerOptions // under the raw provider `name` — no camelCase alias, unlike // openai-compatible — so key by the same name getAIModel passes. - return reasoningEffort || serviceTier + return explicitReasoningEffort || serviceTier ? { [openAiCompatibleProviderName(adapter, connection)]: { - ...(reasoningEffort ? { reasoningEffort } : {}), + ...(explicitReasoningEffort ? { reasoningEffort: explicitReasoningEffort } : {}), ...(serviceTier ? { serviceTier } : {}), }, } : {}; } + const reasoningEffort = + explicitReasoningEffort ?? + (requestedLevel === undefined ? defaultOpenAiReasoningEffort(modelId) : undefined); return { openai: { store: false, ...(reasons || reasoningReplay.contract.reasoningReplay === 'encrypted-content' ? { forceReasoning: true } : {}), + ...openAiResponsesSummary(modelId, reasoningEffort), ...(reasoningEffort ? { reasoningEffort } : {}), ...(serviceTier ? { serviceTier } : {}), }, }; } - if (!reasoningEffort && !serviceTier) return {}; + if (wire === 'anthropic-messages' && (requestedLevel === undefined || level !== undefined)) { + const reasoning = visibleClaudeThinking(modelId, thinkingOptions, explicitReasoningEffort); + if (reasoning) return { anthropic: reasoning }; + } + if (wire === 'openai-chat' && adapter.kind === 'openai-compatible') { + const reasoningEffort = + explicitReasoningEffort ?? + (requestedLevel === undefined ? defaultOpenAiReasoningEffort(modelId) : undefined); + if (reasoningEffort) { + return { + [openAiCompatibleProviderOptionsKey(adapter, connection)]: { reasoningEffort }, + }; + } + } + if (!explicitReasoningEffort && !serviceTier) return {}; switch (adapter.kind) { case 'openai-compatible': return { [openAiCompatibleProviderOptionsKey(adapter, connection)]: { - ...(reasoningEffort ? { reasoningEffort } : {}), + ...(explicitReasoningEffort ? { reasoningEffort: explicitReasoningEffort } : {}), }, }; case 'openai': return { openai: { - ...(reasoningEffort ? { reasoningEffort } : {}), + ...(explicitReasoningEffort ? { reasoningEffort: explicitReasoningEffort } : {}), }, }; case 'anthropic': @@ -677,7 +764,7 @@ function buildFamilyWire( if (copilotProtocol === 'anthropic-messages') { return level !== 'off' ? { anthropic: { effort: level } } : {}; } - return { githubCopilot: { reasoningEffort } }; + return { githubCopilot: { reasoningEffort: explicitReasoningEffort } }; } default: return {}; diff --git a/packages/ui/package.json b/packages/ui/package.json index c8de4eb331..a76b7e493b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,6 +26,7 @@ "@astryxdesign/core": "0.5.0", "@maka/core": "0.1.0", "clsx": "^2.1.1", + "katex": "0.16.47", "lucide-react": "^1.33.0", "mermaid": "^11.17.0", "react": "^19.2.8", diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index ab4b8d8f1b..b33b8287e7 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -46,6 +46,39 @@ it('keeps raw HTML inert instead of expanding the Markdown trust surface', () => assert.doesNotMatch(markup, /
{ + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: '**Calculating CRT Solution**\n\nSet \\( n \\equiv 3 \\pmod 7 \\) and $a = 5$.', + density: 'compact', + })); + + assert.match(markup, /]*>Calculating CRT Solution<\/strong>/); + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.match(markup, /class="katex"/); + assert.doesNotMatch(markup, /\*\*Calculating/); + assert.doesNotMatch(markup, /\\\\\\\(/); +}); + +it('leaves LaTeX delimiters untouched inside inline and fenced code', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: ['Use `\\( x + 1 \\)` literally.', '', '```tex', '\\( y + 2 \\)', '```'].join('\n'), + })); + + assert.doesNotMatch(markup, /class="maka-math/); + assert.match(markup, /\\\( x \+ 1 \\\)/); + assert.match(markup, /\\\( y \+ 2 \\\)/); +}); + +it('renders display math while leaving ordinary currency alone', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'Budget: $5 and $10.\n\n\\[ x^2 + y^2 = z^2 \\]', + })); + + assert.match(markup, /Budget: \$5 and \$10/); + assert.match(markup, /class="maka-math maka-math-display"/); + assert.match(markup, /class="katex-display"/); +}); + it('keeps the copy control in a toolbar above a one-line code scroll viewport', () => { const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', diff --git a/packages/ui/src/astryx-chat-reasoning.tsx b/packages/ui/src/astryx-chat-reasoning.tsx index 009ea76edd..515e959ab1 100644 --- a/packages/ui/src/astryx-chat-reasoning.tsx +++ b/packages/ui/src/astryx-chat-reasoning.tsx @@ -26,6 +26,7 @@ export interface ChatReasoningProps extends HTMLAttributes { children: ReactNode; label?: string; duration?: string; + previewText?: string; isStreaming?: boolean; isExpanded?: boolean; defaultIsExpanded?: boolean; @@ -47,6 +48,7 @@ export function ChatReasoning(props: ChatReasoningProps) { children, label = 'Thinking', duration, + previewText: explicitPreviewText, isStreaming = false, isExpanded: controlledExpanded, defaultIsExpanded = false, @@ -63,7 +65,7 @@ export function ChatReasoning(props: ChatReasoningProps) { if (!isControlled) setInternalExpanded(next); onExpandedChange?.(next); }, [isExpanded, isControlled, onExpandedChange]); - const previewText = typeof children === 'string' ? children : null; + const previewText = explicitPreviewText ?? (typeof children === 'string' ? children : null); return (
- {displayed} + ); } + +function reasoningPreviewText(text: string): string { + const firstLine = text.split('\n').find((line) => line.trim().length > 0)?.trim() ?? ''; + return firstLine + .replace(/^#{1,6}\s+/, '') + .replace(/\\([()[\]])/g, '') + .replace(/\${1,2}/g, '') + .replace(/[*_~`]+/g, '') + .trim(); +} diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index abeef49290..1f9bdc7f26 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -46,6 +46,7 @@ import { MakaUriContext } from './markdown.js'; import { useUiLocale } from './locale-context.js'; import { getSharedUiCopy } from './shared-ui-copy.js'; import { MermaidDiagram } from './mermaid-diagram.js'; +import { prepareMarkdownMath } from './markdown-math.js'; const BASE_MARKDOWN_COMPONENTS = { link: MarkdownLink, @@ -129,10 +130,11 @@ export function MarkdownBody(props: { settledText?: string; density?: 'default' | 'compact'; }) { - const safeText = neutralizeUnsafeMarkdownImages(props.text); + const prepared = prepareMarkdownMath(neutralizeUnsafeMarkdownImages(props.text)); + const safeText = prepared.text; const settledText = props.settledText === undefined ? undefined - : neutralizeUnsafeMarkdownImages(props.settledText); + : prepareMarkdownMath(neutralizeUnsafeMarkdownImages(props.settledText)).text; const budgetedText = props.streaming ? safeText : applyMermaidRenderBudget(safeText); const density = props.density ?? 'default'; const components = props.streaming @@ -174,6 +176,7 @@ export function MarkdownBody(props: { // the one combination neither half of the argument asks for. density={density} components={components} + inlinePlugins={[prepared.plugin]} isStreaming={props.streaming} settledText={settledText} > diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx new file mode 100644 index 0000000000..ed03c30a20 --- /dev/null +++ b/packages/ui/src/markdown-math.tsx @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import katex from 'katex'; +import type { MarkdownInlinePlugin } from '@astryxdesign/core/Markdown'; + +const TOKEN_START = '\uE000MAKAMATH'; +const TOKEN_END = 'END\uE001'; +const TOKEN_PATTERN = new RegExp(`${TOKEN_START}([ID])([0-9A-F]+)${TOKEN_END}`, 'g'); + +export interface PreparedMarkdownMath { + text: string; + plugin: MarkdownInlinePlugin; +} + +export function prepareMarkdownMath(source: string): PreparedMarkdownMath { + return { + text: protectMathOutsideCode(source), + plugin: { + pattern: TOKEN_PATTERN, + render: (match, key) => { + const displayMode = match[1] === 'D'; + const formula = decodeFormula(match[2] ?? ''); + const html = katex.renderToString(formula, { + displayMode, + output: 'htmlAndMathml', + strict: 'warn', + throwOnError: false, + trust: false, + }); + return ( + + ); + }, + }, + }; +} + +function protectMathOutsideCode(source: string): string { + const lines = source.split('\n'); + let fence: { character: string; length: number } | undefined; + + return lines + .map((line) => { + const opening = /^( {0,3})(`{3,}|~{3,})/.exec(line); + if (opening) { + const marker = opening[2] ?? ''; + const character = marker[0] ?? ''; + if (!fence) { + fence = { character, length: marker.length }; + return line; + } + if (character === fence.character && marker.length >= fence.length) { + fence = undefined; + } + return line; + } + return fence ? line : protectMathInLine(line); + }) + .join('\n'); +} + +function protectMathInLine(line: string): string { + let output = ''; + let index = 0; + + while (index < line.length) { + if (line[index] === '`') { + const run = /^`+/.exec(line.slice(index))?.[0] ?? '`'; + const close = line.indexOf(run, index + run.length); + if (close >= 0) { + output += line.slice(index, close + run.length); + index = close + run.length; + continue; + } + } + + const delimited = + readDelimitedMath(line, index, '\\(', '\\)', false) + ?? readDelimitedMath(line, index, '\\[', '\\]', true) + ?? readDelimitedMath(line, index, '$$', '$$', true) + ?? readDollarMath(line, index); + if (delimited) { + output += mathToken(delimited.formula, delimited.displayMode); + index = delimited.end; + continue; + } + + output += line[index]; + index += 1; + } + + return output; +} + +function readDelimitedMath( + line: string, + index: number, + opening: string, + closing: string, + displayMode: boolean, +): { formula: string; displayMode: boolean; end: number } | undefined { + if (!line.startsWith(opening, index)) return undefined; + const close = line.indexOf(closing, index + opening.length); + if (close < 0) return undefined; + const formula = line.slice(index + opening.length, close).trim(); + if (!formula) return undefined; + return { formula, displayMode, end: close + closing.length }; +} + +function readDollarMath( + line: string, + index: number, +): { formula: string; displayMode: false; end: number } | undefined { + if (line[index] !== '$' || line[index - 1] === '\\' || line[index + 1] === '$') { + return undefined; + } + const close = findClosingDollar(line, index + 1); + if (close < 0) return undefined; + const formula = line.slice(index + 1, close); + if (!formula || /^\s|\s$/.test(formula)) return undefined; + return { formula, displayMode: false, end: close + 1 }; +} + +function findClosingDollar(line: string, start: number): number { + for (let index = start; index < line.length; index += 1) { + if (line[index] !== '$' || line[index - 1] === '\\' || line[index + 1] === '$') continue; + return index; + } + return -1; +} + +function mathToken(formula: string, displayMode: boolean): string { + const encoded = Array.from(formula, (character) => + (character.codePointAt(0) ?? 0).toString(16).padStart(6, '0').toUpperCase() + ).join(''); + return `${TOKEN_START}${displayMode ? 'D' : 'I'}${encoded}${TOKEN_END}`; +} + +function decodeFormula(encoded: string): string { + let formula = ''; + for (let index = 0; index < encoded.length; index += 6) { + formula += String.fromCodePoint(Number.parseInt(encoded.slice(index, index + 6), 16)); + } + return formula; +} diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index be4f3e6fbb..742462ed58 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -17,6 +17,8 @@ * under the License. */ +@import 'katex/dist/katex.min.css'; + /* Maka product compositions shared across desktop renderer surfaces. Astryx owns generic component chrome; these selectors own only product semantics. */ @@ -242,13 +244,41 @@ bubble, so it needs its own line and the gap above it. */ .maka-turn-truncation-badge { display: flex; width: fit-content; margin-top: 6px; cursor: help; } .maka-deep-thinking { min-width: 0; } -/* The reasoning body inside the Astryx ChatReasoning disclosure. The official - atoms own no white-space (the shell assumes children are pre-rendered), so - the inherited `white-space: normal` collapses every newline in thinking - text. Restore the pre-wrap reading contract the pre-Astryx disclosure had - (dropped in the #1748 migration). `word-break` keeps long tokens from - overflowing the box, as the old body did. */ -.maka-chat-reasoning-content { white-space: pre-wrap; word-break: break-word; } +/* Reasoning uses the same safe Markdown pipeline as answers, then lowers its + visual weight inside the disclosure. The inset rule makes a long derivation + scannable without turning it into a second answer bubble. */ +.maka-chat-reasoning-content { + margin-block-start: 4px; + margin-inline-start: 22px; + padding: 2px 0 4px 12px; + border-inline-start: 2px solid color-mix(in oklch, var(--link) 32%, var(--border)); + white-space: normal; + word-break: break-word; +} +.maka-deep-thinking [data-maka-contract="markdown"] .astryx-markdown { + color: var(--muted-foreground); + font: var(--maka-text-body); +} +.maka-deep-thinking [data-maka-contract="markdown"] .astryx-markdown-heading, +.maka-deep-thinking [data-maka-contract="markdown"] strong { + color: color-mix(in oklch, var(--link) 28%, var(--foreground-secondary)); +} +.maka-deep-thinking [data-maka-contract="markdown"] .astryx-markdown-heading { + font: var(--maka-text-label); +} +.maka-math { + max-width: 100%; + color: inherit; +} +.maka-math-inline { + display: inline; +} +.maka-math-display { + display: block; + overflow-x: auto; + overflow-y: hidden; + padding-block: 2px; +} @keyframes maka-spin { to { transform: rotate(360deg); } } .maka-spin { animation: maka-spin 1s linear infinite; } From 65e7c201fceab95d1cdbf10d67ead5ebdaeb3e50 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 24 Aug 2026 16:05:14 +0800 Subject: [PATCH 2/4] fix(ui): harden reasoning summary rendering Generated-by: Codex --- .../__tests__/model-factory-thinking.test.ts | 14 ++++++++++++++ .../chat-turn-answer-identity.test.tsx | 17 +++++++++++++++++ packages/ui/src/__tests__/markdown-body.test.ts | 5 +++-- packages/ui/src/chat-turn.tsx | 4 +++- packages/ui/src/markdown-math.tsx | 5 +++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 125af1198e..52102275c6 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -117,6 +117,18 @@ describe('buildProviderOptions: thinking level', () => { thinking: { type: 'enabled', budgetTokens: 1_024 }, }, }); + assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-sonnet-4-5-20250929'), { + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'enabled', budgetTokens: 1_024 }, + }, + }); + assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-opus-4-1-20250805'), { + anthropic: { + cacheControl: { type: 'ephemeral' }, + thinking: { type: 'enabled', budgetTokens: 1_024 }, + }, + }); }); test('anthropic effort model without toggle (opus-4-8) drops off (cannot disable)', () => { @@ -508,6 +520,8 @@ describe('buildProviderOptions: thinking level', () => { myRelay: { reasoningEffort: 'medium' }, }); assert.deepEqual(buildProviderOptions(openaiRelay, 'gpt-5.6-sol', 'minimal'), {}); + assert.deepEqual(buildProviderOptions(openaiRelay, 'gpt-5.6-sol', 'off'), {}); + assert.deepEqual(buildProviderOptions(openaiRelay, 'gpt-5.6-sol', 'high'), {}); assert.deepEqual(buildProviderOptions(conn('anthropic-compatible'), 'claude-opus-4-8'), { anthropic: { diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index 7255c315f6..7408bce901 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -140,6 +140,23 @@ test('keeps the assistant answer element as a turn settles around it', async () ); }); +test('redacts secrets before rendering a settled collapsed reasoning preview', async () => { + const { container, root } = domRoot(); + await renderTurn(root, turnWith([ + { + kind: 'thinking', + text: 'Authorization: Bearer sk-live-1234567890abcdef\n\nSafe detail', + messageId: 'thinking-1', + live: false, + }, + ])); + + const header = container.querySelector('[data-slot="activity-card-header"]'); + assert.ok(header); + assert.match(header.textContent ?? '', //); + assert.doesNotMatch(header.textContent ?? '', /sk-live-1234567890abcdef/); +}); + /** * Extends the regression above to a steered turn: both segments exist side by * side and each keeps its own element across the settle. It does not pin the diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index b33b8287e7..5af5c01370 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -71,12 +71,13 @@ it('leaves LaTeX delimiters untouched inside inline and fenced code', () => { it('renders display math while leaving ordinary currency alone', () => { const markup = renderToStaticMarkup(createElement(MarkdownBody, { - text: 'Budget: $5 and $10.\n\n\\[ x^2 + y^2 = z^2 \\]', + text: 'Budget: $5 and $10. Range: $5–$10.\n\n\\[ x^2 + y^2 = z^2 \\]', })); - assert.match(markup, /Budget: \$5 and \$10/); + assert.match(markup, /Budget: \$5 and \$10\. Range: \$5–\$10/); assert.match(markup, /class="maka-math maka-math-display"/); assert.match(markup, /class="katex-display"/); + assert.doesNotMatch(markup, /class="maka-math maka-math-inline"/); }); it('keeps the copy control in a toolbar above a one-line code scroll viewport', () => { diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 6da57e1219..e986c1c347 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -72,6 +72,7 @@ import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { AstryxLocaleProvider } from './astryx-i18n.js'; import { InlineReferenceText } from './inline-reference.js'; +import { redactSecrets } from './redact.js'; export function LocalizedChatMessage({ accessibleLabel, @@ -1304,7 +1305,8 @@ function DeepThinking(props: { text: string; live: boolean; settledText?: string } function reasoningPreviewText(text: string): string { - const firstLine = text.split('\n').find((line) => line.trim().length > 0)?.trim() ?? ''; + const safeText = redactSecrets(text); + const firstLine = safeText.split('\n').find((line) => line.trim().length > 0)?.trim() ?? ''; return firstLine .replace(/^#{1,6}\s+/, '') .replace(/\\([()[\]])/g, '') diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index ed03c30a20..7c3f94e9fd 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -139,9 +139,14 @@ function readDollarMath( if (close < 0) return undefined; const formula = line.slice(index + 1, close); if (!formula || /^\s|\s$/.test(formula)) return undefined; + if (isPairedCurrencyRange(line, index, close)) return undefined; return { formula, displayMode: false, end: close + 1 }; } +function isPairedCurrencyRange(line: string, opening: number, closing: number): boolean { + return /\d/.test(line[opening + 1] ?? '') && /\d/.test(line[closing + 1] ?? ''); +} + function findClosingDollar(line: string, start: number): number { for (let index = start; index < line.length; index += 1) { if (line[index] !== '$' || line[index - 1] === '\\' || line[index + 1] === '$') continue; From c0272fb1c700fdf68ef8196285c581a6c09c58a3 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 24 Aug 2026 20:33:55 +0800 Subject: [PATCH 3/4] fix(ui): preserve multiline reasoning math Generated-by: Codex --- packages/runtime/src/model-factory.ts | 2 + .../chat-turn-answer-identity.test.tsx | 27 +++++++ .../ui/src/__tests__/markdown-body.test.ts | 28 +++++++ packages/ui/src/markdown-math.tsx | 81 +++++++++++-------- packages/ui/src/styles.css | 2 +- 5 files changed, 106 insertions(+), 34 deletions(-) diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 1c7d0712e2..5930c78527 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -18,6 +18,8 @@ */ import { createAnthropic } from '@ai-sdk/anthropic'; +// Load-bearing until the public Anthropic API exposes model thinking mode: +// replace this capability lookup when upgrading if the exported internal path disappears. import { getModelCapabilities as getAnthropicModelCapabilities } from '@ai-sdk/anthropic/internal'; import { createCohere } from '@ai-sdk/cohere'; import { createGoogle } from '@ai-sdk/google'; diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index 7408bce901..ce90da096b 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -18,6 +18,8 @@ */ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { afterEach, test } from 'node:test'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; @@ -157,6 +159,31 @@ test('redacts secrets before rendering a settled collapsed reasoning preview', a assert.doesNotMatch(header.textContent ?? '', /sk-live-1234567890abcdef/); }); +test('preserves a model-authored single newline in plain reasoning', async () => { + const { container, root } = domRoot(); + await renderTurn(root, turnWith([ + { + kind: 'thinking', + text: 'First observation\nSecond observation', + messageId: 'thinking-1', + live: false, + }, + ])); + + const body = container.querySelector('.maka-chat-reasoning-content'); + assert.ok(body); + assert.match(body.textContent ?? '', /First observation\nSecond observation/); + + const css = await readFile(resolve(import.meta.dirname, '..', '..', 'src', 'styles.css'), 'utf8'); + const rule = /\.maka-chat-reasoning-content\s*\{([^}]*)\}/.exec(css); + assert.ok(rule, 'the reasoning body style contract is missing'); + assert.match( + rule[1] ?? '', + /white-space\s*:\s*pre-wrap/, + 'single model-authored newlines must remain visible after Markdown renders a soft break', + ); +}); + /** * Extends the regression above to a steered turn: both segments exist side by * side and each keeps its own element across the settle. It does not pin the diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 5af5c01370..92d0f61e35 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -80,6 +80,34 @@ it('renders display math while leaving ordinary currency alone', () => { assert.doesNotMatch(markup, /class="maka-math maka-math-inline"/); }); +it('renders multiline display math outside code for both supported delimiters', () => { + for (const [text, mathNode] of [ + [['Before', '', '$$', 'E = mc^2', '$$', '', 'After'].join('\n'), ''], + [['Before', '', '\\[', 'x_1 + x_2 = y', '\\]', '', 'After'].join('\n'), ''], + ]) { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { text })); + + assert.match(markup, /Before/); + assert.match(markup, /After/); + assert.match(markup, /class="maka-math maka-math-display"/); + assert.match(markup, /class="katex-display"/); + assert.doesNotMatch(markup, /\$\$/); + assert.doesNotMatch(markup, /\\\[/); + assert.match(markup, new RegExp(mathNode)); + assert.doesNotMatch(markup, /]*>1<\/em>/); + } +}); + +it('does not let multiline display math cross a fenced code block', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: ['$$', 'outside', '```tex', 'inside', '```', '$$'].join('\n'), + })); + + assert.doesNotMatch(markup, /class="maka-math/); + assert.match(markup, /\$\$/); + assert.match(markup, /inside/); +}); + it('keeps the copy control in a toolbar above a one-line code scroll viewport', () => { const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 7c3f94e9fd..db5f9b69f3 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -59,54 +59,65 @@ export function prepareMarkdownMath(source: string): PreparedMarkdownMath { function protectMathOutsideCode(source: string): string { const lines = source.split('\n'); let fence: { character: string; length: number } | undefined; + let proseLines: string[] = []; + const protectedParts: string[] = []; - return lines - .map((line) => { - const opening = /^( {0,3})(`{3,}|~{3,})/.exec(line); - if (opening) { - const marker = opening[2] ?? ''; - const character = marker[0] ?? ''; - if (!fence) { - fence = { character, length: marker.length }; - return line; - } - if (character === fence.character && marker.length >= fence.length) { - fence = undefined; - } - return line; + const flushProse = () => { + if (proseLines.length === 0) return; + protectedParts.push(protectMathInProse(proseLines.join('\n'))); + proseLines = []; + }; + + for (const line of lines) { + const opening = /^( {0,3})(`{3,}|~{3,})/.exec(line); + if (opening) { + flushProse(); + const marker = opening[2] ?? ''; + const character = marker[0] ?? ''; + if (!fence) { + fence = { character, length: marker.length }; + } else if (character === fence.character && marker.length >= fence.length) { + fence = undefined; } - return fence ? line : protectMathInLine(line); - }) - .join('\n'); + protectedParts.push(line); + } else if (fence) { + protectedParts.push(line); + } else { + proseLines.push(line); + } + } + flushProse(); + + return protectedParts.join('\n'); } -function protectMathInLine(line: string): string { +function protectMathInProse(source: string): string { let output = ''; let index = 0; - while (index < line.length) { - if (line[index] === '`') { - const run = /^`+/.exec(line.slice(index))?.[0] ?? '`'; - const close = line.indexOf(run, index + run.length); + while (index < source.length) { + if (source[index] === '`') { + const run = /^`+/.exec(source.slice(index))?.[0] ?? '`'; + const close = source.indexOf(run, index + run.length); if (close >= 0) { - output += line.slice(index, close + run.length); + output += source.slice(index, close + run.length); index = close + run.length; continue; } } const delimited = - readDelimitedMath(line, index, '\\(', '\\)', false) - ?? readDelimitedMath(line, index, '\\[', '\\]', true) - ?? readDelimitedMath(line, index, '$$', '$$', true) - ?? readDollarMath(line, index); + readDelimitedMath(source, index, '\\(', '\\)', false, false) + ?? readDelimitedMath(source, index, '\\[', '\\]', true, true) + ?? readDelimitedMath(source, index, '$$', '$$', true, true) + ?? readDollarMath(source, index); if (delimited) { output += mathToken(delimited.formula, delimited.displayMode); index = delimited.end; continue; } - output += line[index]; + output += source[index]; index += 1; } @@ -119,11 +130,14 @@ function readDelimitedMath( opening: string, closing: string, displayMode: boolean, + allowNewlines: boolean, ): { formula: string; displayMode: boolean; end: number } | undefined { if (!line.startsWith(opening, index)) return undefined; - const close = line.indexOf(closing, index + opening.length); + const contentStart = index + opening.length; + const close = line.indexOf(closing, contentStart); if (close < 0) return undefined; - const formula = line.slice(index + opening.length, close).trim(); + if (!allowNewlines && line.slice(contentStart, close).includes('\n')) return undefined; + const formula = line.slice(contentStart, close).trim(); if (!formula) return undefined; return { formula, displayMode, end: close + closing.length }; } @@ -135,7 +149,8 @@ function readDollarMath( if (line[index] !== '$' || line[index - 1] === '\\' || line[index + 1] === '$') { return undefined; } - const close = findClosingDollar(line, index + 1); + const lineEnd = line.indexOf('\n', index + 1); + const close = findClosingDollar(line, index + 1, lineEnd < 0 ? line.length : lineEnd); if (close < 0) return undefined; const formula = line.slice(index + 1, close); if (!formula || /^\s|\s$/.test(formula)) return undefined; @@ -147,8 +162,8 @@ function isPairedCurrencyRange(line: string, opening: number, closing: number): return /\d/.test(line[opening + 1] ?? '') && /\d/.test(line[closing + 1] ?? ''); } -function findClosingDollar(line: string, start: number): number { - for (let index = start; index < line.length; index += 1) { +function findClosingDollar(line: string, start: number, end: number): number { + for (let index = start; index < end; index += 1) { if (line[index] !== '$' || line[index - 1] === '\\' || line[index + 1] === '$') continue; return index; } diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 742462ed58..04f5a0c296 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -252,7 +252,7 @@ margin-inline-start: 22px; padding: 2px 0 4px 12px; border-inline-start: 2px solid color-mix(in oklch, var(--link) 32%, var(--border)); - white-space: normal; + white-space: pre-wrap; word-break: break-word; } .maka-deep-thinking [data-maka-contract="markdown"] .astryx-markdown { From 384273a63f2a0cc14e05f9a933c14734519dff47 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Tue, 25 Aug 2026 01:06:08 +0800 Subject: [PATCH 4/4] fix: close reasoning display review gaps Generated-by: Codex --- docs/astryx-surface-file-inventory.md | 2 +- .../__tests__/model-factory-thinking.test.ts | 74 +++++++++- .../__tests__/provider-conformance.test.ts | 5 + packages/runtime/src/model-factory.ts | 59 ++++++-- .../chat-turn-answer-identity.test.tsx | 16 +++ .../ui/src/__tests__/markdown-body.test.ts | 50 ++++++- packages/ui/src/chat-turn.tsx | 2 +- packages/ui/src/markdown-body.tsx | 10 +- packages/ui/src/markdown-math.tsx | 128 ++++++++++-------- 9 files changed, 267 insertions(+), 79 deletions(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 50c52e4acf..45a209a805 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 217 files — blocker 0, polish 1, aligned 216. +**Totals:** 218 files — blocker 0, polish 1, aligned 217. ## Exclusions (explicit) diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 52102275c6..bb1126f0ea 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -19,10 +19,13 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; +import { lookupModelMetadata } from '@maka/core/model-metadata'; import { thinkingVariantsForModel, type ThinkingLevel } from '@maka/core/model-thinking'; +import { isRetiredProvider } from '@maka/core/provider-registry'; import { buildProviderOptions, getAIModel } from '../model-factory.js'; +import { resolveModelRuntime } from '../model-runtime.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { return { @@ -458,6 +461,11 @@ describe('buildProviderOptions: thinking level', () => { effort: 'high', }, }); + assert.deepEqual(buildProviderOptions(conn('opencode'), 'claude-sonnet-4'), { + anthropic: { + thinking: { type: 'enabled', budgetTokens: 1_024 }, + }, + }); assert.deepEqual(buildProviderOptions(conn('opencode'), 'gemini-3.5-flash', 'high'), { google: { thinkingConfig: { includeThoughts: true, thinkingLevel: 'high' } }, }); @@ -471,6 +479,65 @@ describe('buildProviderOptions: thinking level', () => { }); }); + test('every active shipped Claude model on Anthropic Messages requests visible thinking', () => { + const activeClaudeModels: Array<{ + connection: LlmConnection; + modelId: string; + }> = []; + for (const providerType of Object.keys(PROVIDER_DEFAULTS) as LlmConnection['providerType'][]) { + if (isRetiredProvider(providerType)) continue; + const connection = conn(providerType); + for (const modelId of PROVIDER_DEFAULTS[providerType].fallbackModels) { + const familyModelId = modelId.includes('/') + ? modelId.slice(modelId.lastIndexOf('/') + 1) + : modelId; + const metadata = lookupModelMetadata(providerType, modelId); + if ( + familyModelId.startsWith('claude-') && + metadata.lifecycle === 'active' && + metadata.capabilities?.reasoning === true && + resolveModelRuntime(connection, modelId).wire === 'anthropic-messages' + ) { + activeClaudeModels.push({ connection, modelId }); + } + } + } + + assert.equal(activeClaudeModels.length, 13); + assert.ok( + activeClaudeModels.some( + ({ connection, modelId }) => + connection.providerType === 'opencode' && modelId === 'claude-sonnet-4', + ), + ); + for (const { connection, modelId } of activeClaudeModels) { + const thinking = ( + buildProviderOptions(connection, modelId).anthropic as + | { thinking?: { type?: string; display?: string; budgetTokens?: number } } + | undefined + )?.thinking; + assert.ok(thinking, `${connection.providerType}/${modelId} must request visible thinking`); + if (thinking.type === 'adaptive') { + assert.equal(thinking.display, 'summarized', `${connection.providerType}/${modelId}`); + } else { + assert.deepEqual( + thinking, + { type: 'enabled', budgetTokens: 1_024 }, + `${connection.providerType}/${modelId}`, + ); + } + } + }); + + test('unknown non-Claude models on Anthropic Messages do not inherit Claude thinking', () => { + const connection = { + ...conn('opencode'), + models: [{ id: 'custom-reasoner', apiProtocol: 'anthropic-messages' as const }], + }; + + assert.deepEqual(buildProviderOptions(connection, 'custom-reasoner'), {}); + }); + test('github-copilot routes thinking by the account-declared model protocol', () => { const anthropic = { ...conn('github-copilot'), @@ -528,6 +595,11 @@ describe('buildProviderOptions: thinking level', () => { thinking: { type: 'adaptive', display: 'summarized' }, }, }); + assert.deepEqual(buildProviderOptions(conn('anthropic-compatible'), 'claude-sonnet-4'), { + anthropic: { + thinking: { type: 'enabled', budgetTokens: 1_024 }, + }, + }); assert.deepEqual( buildProviderOptions(conn('anthropic-compatible'), 'claude-opus-4-8', 'off'), {}, diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 87e0d64db3..e433532eb1 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -148,6 +148,11 @@ describe('models.dev provider conformance', () => { providerType: 'anthropic' as const, expectedThinking: { type: 'adaptive', display: 'summarized' }, }, + { + modelId: 'claude-sonnet-4', + providerType: 'opencode' as const, + expectedThinking: { type: 'enabled', budget_tokens: 1_024 }, + }, { modelId: 'anthropic/claude-opus-4.5', providerType: 'anthropic-compatible' as const, diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 5930c78527..1154914864 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -36,6 +36,7 @@ import { } from '@ai-sdk/provider'; import { type RuntimeExecutionConnection } from '@maka/core/llm-connections'; import type { ProviderRuntimeAdapter } from '@maka/core/llm-connections'; +import { lookupModelMetadata } from '@maka/core/model-metadata'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import { resolveThinkingLevel, @@ -402,26 +403,57 @@ function openAiResponsesSummary(modelId: string, reasoningEffort: string | undef } function visibleClaudeThinking( + providerType: RuntimeExecutionConnection['providerType'], modelId: string, thinkingOptions: ThinkingOptions | undefined, effort: string | undefined, ) { - const familyModelId = claudeFamilyId(modelId); - if (!familyModelId.startsWith('claude-')) return undefined; - const effectiveOptions = thinkingOptions ?? thinkingOptionsForModel('anthropic', familyModelId); - const supportsThinking = - effectiveOptions?.toggle === true || (effectiveOptions?.efforts?.length ?? 0) > 0; - if (!supportsThinking) return undefined; + const mode = claudeThinkingMode(providerType, modelId, thinkingOptions); + if (!mode) return undefined; - const thinking = getAnthropicModelCapabilities(familyModelId).supportsAdaptiveThinking - ? { type: 'adaptive' as const, display: 'summarized' as const } - : { type: 'enabled' as const, budgetTokens: 1_024 }; + const thinking = + mode === 'adaptive' + ? { type: 'adaptive' as const, display: 'summarized' as const } + : { type: 'enabled' as const, budgetTokens: 1_024 }; return { thinking, ...(effort ? { effort } : {}), }; } +function claudeThinkingMode( + providerType: RuntimeExecutionConnection['providerType'], + modelId: string, + thinkingOptions: ThinkingOptions | undefined, +): 'adaptive' | 'legacy' | undefined { + const familyModelId = claudeFamilyId(modelId); + if (!familyModelId.startsWith('claude-')) return undefined; + + const providerMetadata = lookupModelMetadata(providerType, modelId); + const anthropicMetadata = lookupModelMetadata('anthropic', familyModelId); + const isKnownBareLegacyClaude4 = /^claude-(?:opus|sonnet)-4$/.test(familyModelId); + const effectiveOptions = + thinkingOptions ?? + providerMetadata.thinkingOptions ?? + anthropicMetadata.thinkingOptions ?? + thinkingOptionsForModel('anthropic', familyModelId); + const supportsThinking = + effectiveOptions?.toggle === true || + (effectiveOptions?.efforts?.length ?? 0) > 0 || + providerMetadata.capabilities?.reasoning === true || + anthropicMetadata.capabilities?.reasoning === true || + isKnownBareLegacyClaude4; + if (!supportsThinking) return undefined; + + // The SDK's capability table only recognizes dated Claude 4 aliases. The + // active bare aliases are the same legacy budget-thinking families. + if (isKnownBareLegacyClaude4) return 'legacy'; + + return getAnthropicModelCapabilities(familyModelId).supportsAdaptiveThinking + ? 'adaptive' + : 'legacy'; +} + export function buildProviderOptions( connection: RuntimeExecutionConnection, modelId: string, @@ -494,7 +526,7 @@ function buildThinkingProviderOptions( const summarizedThinking = connection.providerType === 'anthropic' && (thinkingLevel === undefined || level !== undefined) - ? visibleClaudeThinking(modelId, thinkingOptions, level) + ? visibleClaudeThinking(connection.providerType, modelId, thinkingOptions, level) : undefined; if (level === 'off' && thinkingOptions?.offBehavior === 'anthropic-thinking-disabled') { reasoning = { thinking: { type: 'disabled' as const } }; @@ -716,7 +748,12 @@ function buildFamilyWire( }; } if (wire === 'anthropic-messages' && (requestedLevel === undefined || level !== undefined)) { - const reasoning = visibleClaudeThinking(modelId, thinkingOptions, explicitReasoningEffort); + const reasoning = visibleClaudeThinking( + connection.providerType, + modelId, + thinkingOptions, + explicitReasoningEffort, + ); if (reasoning) return { anthropic: reasoning }; } if (wire === 'openai-chat' && adapter.kind === 'openai-compatible') { diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index ce90da096b..ce6052f940 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -159,6 +159,22 @@ test('redacts secrets before rendering a settled collapsed reasoning preview', a assert.doesNotMatch(header.textContent ?? '', /sk-live-1234567890abcdef/); }); +test('preserves currency in a settled collapsed reasoning preview', async () => { + const { container, root } = domRoot(); + await renderTurn(root, turnWith([ + { + kind: 'thinking', + text: 'The estimated cost is $5, not $$x + 1$$.', + messageId: 'thinking-1', + live: false, + }, + ])); + + const header = container.querySelector('[data-slot="activity-card-header"]'); + assert.ok(header); + assert.match(header.textContent ?? '', /cost is \$5, not x \+ 1/); +}); + test('preserves a model-authored single newline in plain reasoning', async () => { const { container, root } = domRoot(); await renderTurn(root, turnWith([ diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 92d0f61e35..5ff813f702 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -48,7 +48,7 @@ it('keeps raw HTML inert instead of expanding the Markdown trust surface', () => it('renders Markdown emphasis and LaTeX without exposing their source delimiters', () => { const markup = renderToStaticMarkup(createElement(MarkdownBody, { - text: '**Calculating CRT Solution**\n\nSet \\( n \\equiv 3 \\pmod 7 \\) and $a = 5$.', + text: '**Calculating CRT Solution**\n\nSet \\( n \\equiv 3 \\pmod 7 \\) and \\( a = 5 \\).', density: 'compact', })); @@ -80,6 +80,40 @@ it('renders display math while leaving ordinary currency alone', () => { assert.doesNotMatch(markup, /class="maka-math maka-math-inline"/); }); +it('does not treat shell variables, currency, or inline code as dollar-delimited math', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: [ + 'Home: $HOME/$USER', + 'Path: $PATH:$HOME', + 'Prices: $5 and $10; range $5–$10; paired $5 and $10.', + 'Literal: `$x$`', + 'Explicit: \\( x + 1 \\)', + ].join('\n\n'), + })); + + assert.match(markup, /\$HOME\/\$USER/); + assert.match(markup, /\$PATH:\$HOME/); + assert.match(markup, /\$5 and \$10; range \$5–\$10; paired \$5 and \$10/); + assert.match(markup, /]*>\$x\$<\/code>/); + assert.equal((markup.match(/class="maka-math maka-math-inline"/g) ?? []).length, 1); + assert.match(markup, /class="katex"/); +}); + +it('keeps raw and malformed internal-token lookalikes literal', () => { + for (const token of [ + '\uE000MAKAMATHIFFFFFFEND\uE001', + '\uE000MAKAMATH:0:0:\uE001', + '\uE000MAKAMATH:999:not-a-token:\uE001', + ]) { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: `Before ${token} after with \\( x + 1 \\).`, + })); + + assert.match(markup, new RegExp(token)); + assert.equal((markup.match(/class="maka-math maka-math-inline"/g) ?? []).length, 1); + } +}); + it('renders multiline display math outside code for both supported delimiters', () => { for (const [text, mathNode] of [ [['Before', '', '$$', 'E = mc^2', '$$', '', 'After'].join('\n'), ''], @@ -361,6 +395,20 @@ it('shows only the restored prefix on its first streaming render', () => { assert.doesNotMatch(markup, /new delta/); }); +it('uses the same protected math registry for live and settled streaming text', () => { + const rawToken = '\uE000MAKAMATH:0:0:\uE001'; + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: `Stable ${rawToken} \\( x + 1 \\) with a new delta`, + streaming: true, + settledText: `Stable ${rawToken} \\( x + 1 \\)`, + })); + + assert.match(markup, new RegExp(rawToken)); + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.match(markup, /class="katex"/); + assert.doesNotMatch(markup, /new delta/); +}); + it('settles only the verified prefix when restored content was rewritten', () => { const markup = renderToStaticMarkup(createElement(MarkdownBody, { text: 'prefix NEW', diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index e986c1c347..7dc662ce70 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1310,7 +1310,7 @@ function reasoningPreviewText(text: string): string { return firstLine .replace(/^#{1,6}\s+/, '') .replace(/\\([()[\]])/g, '') - .replace(/\${1,2}/g, '') + .replace(/\$\$/g, '') .replace(/[*_~`]+/g, '') .trim(); } diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index 1f9bdc7f26..bdc08ae4d0 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -130,11 +130,11 @@ export function MarkdownBody(props: { settledText?: string; density?: 'default' | 'compact'; }) { - const prepared = prepareMarkdownMath(neutralizeUnsafeMarkdownImages(props.text)); + const source = neutralizeUnsafeMarkdownImages(props.text); + const settledSource = + props.settledText === undefined ? undefined : neutralizeUnsafeMarkdownImages(props.settledText); + const prepared = prepareMarkdownMath(source, settledSource); const safeText = prepared.text; - const settledText = props.settledText === undefined - ? undefined - : prepareMarkdownMath(neutralizeUnsafeMarkdownImages(props.settledText)).text; const budgetedText = props.streaming ? safeText : applyMermaidRenderBudget(safeText); const density = props.density ?? 'default'; const components = props.streaming @@ -178,7 +178,7 @@ export function MarkdownBody(props: { components={components} inlinePlugins={[prepared.plugin]} isStreaming={props.streaming} - settledText={settledText} + settledText={prepared.settledText} > {budgetedText} diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index db5f9b69f3..eccd8cbb98 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -20,25 +20,34 @@ import katex from 'katex'; import type { MarkdownInlinePlugin } from '@astryxdesign/core/Markdown'; -const TOKEN_START = '\uE000MAKAMATH'; -const TOKEN_END = 'END\uE001'; -const TOKEN_PATTERN = new RegExp(`${TOKEN_START}([ID])([0-9A-F]+)${TOKEN_END}`, 'g'); - export interface PreparedMarkdownMath { text: string; + settledText?: string; plugin: MarkdownInlinePlugin; } -export function prepareMarkdownMath(source: string): PreparedMarkdownMath { +interface MathTokenValue { + formula: string; + displayMode: boolean; +} + +export function prepareMarkdownMath( + source: string, + settledSource?: string, +): PreparedMarkdownMath { + const registry = createMathTokenRegistry([source, settledSource]); return { - text: protectMathOutsideCode(source), + text: protectMathOutsideCode(source, registry.register), + ...(settledSource === undefined + ? {} + : { settledText: protectMathOutsideCode(settledSource, registry.register) }), plugin: { - pattern: TOKEN_PATTERN, + pattern: registry.pattern, render: (match, key) => { - const displayMode = match[1] === 'D'; - const formula = decodeFormula(match[2] ?? ''); - const html = katex.renderToString(formula, { - displayMode, + const value = registry.values.get(match[0]); + if (!value) return match[0]; + const html = katex.renderToString(value.formula, { + displayMode: value.displayMode, output: 'htmlAndMathml', strict: 'warn', throwOnError: false, @@ -47,7 +56,9 @@ export function prepareMarkdownMath(source: string): PreparedMarkdownMath { return ( ); @@ -56,7 +67,43 @@ export function prepareMarkdownMath(source: string): PreparedMarkdownMath { }; } -function protectMathOutsideCode(source: string): string { +function createMathTokenRegistry(sources: Array): { + pattern: RegExp; + register: (formula: string, displayMode: boolean) => string; + values: Map; +} { + let namespaceIndex = 0; + let namespace = ''; + do { + namespace = `\uE000MAKAMATH:${namespaceIndex}:`; + namespaceIndex += 1; + } while (sources.some((source) => source?.includes(namespace))); + + const tokenEnd = ':\uE001'; + const values = new Map(); + const tokensByValue = new Map(); + let nextTokenId = 0; + return { + pattern: new RegExp(`${escapeRegExp(namespace)}\\d+${escapeRegExp(tokenEnd)}`, 'g'), + register: (formula, displayMode) => { + const valueKey = JSON.stringify([displayMode, formula]); + const existingToken = tokensByValue.get(valueKey); + if (existingToken) return existingToken; + + const token = `${namespace}${nextTokenId}${tokenEnd}`; + nextTokenId += 1; + values.set(token, { formula, displayMode }); + tokensByValue.set(valueKey, token); + return token; + }, + values, + }; +} + +function protectMathOutsideCode( + source: string, + register: (formula: string, displayMode: boolean) => string, +): string { const lines = source.split('\n'); let fence: { character: string; length: number } | undefined; let proseLines: string[] = []; @@ -64,7 +111,7 @@ function protectMathOutsideCode(source: string): string { const flushProse = () => { if (proseLines.length === 0) return; - protectedParts.push(protectMathInProse(proseLines.join('\n'))); + protectedParts.push(protectMathInProse(proseLines.join('\n'), register)); proseLines = []; }; @@ -91,7 +138,10 @@ function protectMathOutsideCode(source: string): string { return protectedParts.join('\n'); } -function protectMathInProse(source: string): string { +function protectMathInProse( + source: string, + register: (formula: string, displayMode: boolean) => string, +): string { let output = ''; let index = 0; @@ -109,10 +159,9 @@ function protectMathInProse(source: string): string { const delimited = readDelimitedMath(source, index, '\\(', '\\)', false, false) ?? readDelimitedMath(source, index, '\\[', '\\]', true, true) - ?? readDelimitedMath(source, index, '$$', '$$', true, true) - ?? readDollarMath(source, index); + ?? readDelimitedMath(source, index, '$$', '$$', true, true); if (delimited) { - output += mathToken(delimited.formula, delimited.displayMode); + output += register(delimited.formula, delimited.displayMode); index = delimited.end; continue; } @@ -142,45 +191,6 @@ function readDelimitedMath( return { formula, displayMode, end: close + closing.length }; } -function readDollarMath( - line: string, - index: number, -): { formula: string; displayMode: false; end: number } | undefined { - if (line[index] !== '$' || line[index - 1] === '\\' || line[index + 1] === '$') { - return undefined; - } - const lineEnd = line.indexOf('\n', index + 1); - const close = findClosingDollar(line, index + 1, lineEnd < 0 ? line.length : lineEnd); - if (close < 0) return undefined; - const formula = line.slice(index + 1, close); - if (!formula || /^\s|\s$/.test(formula)) return undefined; - if (isPairedCurrencyRange(line, index, close)) return undefined; - return { formula, displayMode: false, end: close + 1 }; -} - -function isPairedCurrencyRange(line: string, opening: number, closing: number): boolean { - return /\d/.test(line[opening + 1] ?? '') && /\d/.test(line[closing + 1] ?? ''); -} - -function findClosingDollar(line: string, start: number, end: number): number { - for (let index = start; index < end; index += 1) { - if (line[index] !== '$' || line[index - 1] === '\\' || line[index + 1] === '$') continue; - return index; - } - return -1; -} - -function mathToken(formula: string, displayMode: boolean): string { - const encoded = Array.from(formula, (character) => - (character.codePointAt(0) ?? 0).toString(16).padStart(6, '0').toUpperCase() - ).join(''); - return `${TOKEN_START}${displayMode ? 'D' : 'I'}${encoded}${TOKEN_END}`; -} - -function decodeFormula(encoded: string): string { - let formula = ''; - for (let index = 0; index < encoded.length; index += 6) { - formula += String.fromCodePoint(Number.parseInt(encoded.slice(index, index + 6), 16)); - } - return formula; +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }