diff --git a/desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh b/desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh new file mode 100755 index 00000000..9c2b2e53 --- /dev/null +++ b/desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh @@ -0,0 +1,325 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required for this UI E2E" >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ARTIFACT_DIR="${ARTIFACT_DIR:-/tmp/cc-haha-e2e-deepseek-thinking-$$}" +CONFIG_DIR="${ARTIFACT_DIR}/config" +UPSTREAM_PORT="${UPSTREAM_PORT:-49391}" +API_PORT="${API_PORT:-49392}" +WEB_PORT="${WEB_PORT:-49393}" +SESSION_NAME="${SESSION_NAME:-cc-haha-deepseek-thinking-ui-$$}" + +mkdir -p "${ARTIFACT_DIR}" + +UPSTREAM_PID="" +SERVER_PID="" +WEB_PID="" + +cleanup() { + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ -n "${WEB_PID}" ]]; then kill "${WEB_PID}" >/dev/null 2>&1 || true; fi + if [[ -n "${SERVER_PID}" ]]; then kill "${SERVER_PID}" >/dev/null 2>&1 || true; fi + if [[ -n "${UPSTREAM_PID}" ]]; then kill "${UPSTREAM_PID}" >/dev/null 2>&1 || true; fi + wait "${WEB_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${UPSTREAM_PID}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +ARTIFACT_DIR="${ARTIFACT_DIR}" UPSTREAM_PORT="${UPSTREAM_PORT}" node --input-type=module <<'NODE' & +import http from 'node:http' +import { mkdirSync, writeFileSync } from 'node:fs' + +const artifactDir = process.env.ARTIFACT_DIR +const port = Number(process.env.UPSTREAM_PORT) +mkdirSync(artifactDir, { recursive: true }) +const captures = [] + +function record(req, body) { + captures.push({ + ts: new Date().toISOString(), + method: req.method, + url: req.url, + headers: req.headers, + body, + }) + writeFileSync(`${artifactDir}/captures.json`, JSON.stringify(captures, null, 2)) +} + +function anthropicSse() { + return [ + 'event: message_start', + 'data: {"type":"message_start","message":{"id":"msg_ui_test","type":"message","role":"assistant","model":"deepseek-v4-pro","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}', + '', + 'event: content_block_start', + 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}', + '', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}', + '', + 'event: content_block_stop', + 'data: {"type":"content_block_stop","index":0}', + '', + 'event: message_delta', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}', + '', + 'event: message_stop', + 'data: {"type":"message_stop"}', + '', + ].join('\n') +} + +function openaiChatSse() { + return [ + 'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}', + '', + 'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}', + '', + 'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', + '', + 'data: [DONE]', + '', + ].join('\n') +} + +http.createServer((req, res) => { + let raw = '' + req.on('data', chunk => { raw += chunk }) + req.on('end', () => { + let body = {} + try { + body = raw ? JSON.parse(raw) : {} + } catch { + body = { raw } + } + record(req, body) + + const hasConflict = + body?.thinking?.type === 'disabled' && + body?.output_config?.effort !== undefined + if (hasConflict) { + res.writeHead(400, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ + error: { + message: 'thinking options type cannot be disabled when reasoning_effort is set', + type: 'invalid_request_error', + param: null, + code: 'invalid_request_error', + }, + })) + return + } + + if (req.url?.includes('/v1/chat/completions')) { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end(openaiChatSse()) + return + } + + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end(anthropicSse()) + }) +}).listen(port, '127.0.0.1', () => { + console.log(`fake upstream listening on ${port}`) +}) +NODE +UPSTREAM_PID=$! + +( + cd "${ROOT_DIR}" + CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" \ + bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}" +) & +SERVER_PID=$! + +( + cd "${ROOT_DIR}/desktop" + VITE_DESKTOP_SERVER_URL="http://127.0.0.1:${API_PORT}" \ + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" +) & +WEB_PID=$! + +wait_for_url() { + local url="$1" + for _ in {1..80}; do + if curl -fsS "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + echo "Timed out waiting for ${url}" >&2 + exit 1 +} + +wait_for_url "http://127.0.0.1:${API_PORT}/health" +wait_for_url "http://127.0.0.1:${WEB_PORT}" + +AB=(agent-browser --session "${SESSION_NAME}") +APP_URL="http://127.0.0.1:${WEB_PORT}" +UPSTREAM_URL="http://127.0.0.1:${UPSTREAM_PORT}" + +"${AB[@]}" open "${APP_URL}" >/dev/null + +ui_eval() { + "${AB[@]}" eval "$1" >/dev/null +} + +ui_eval " +const buttonByName = (name) => [...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === name); +buttonByName('设置')?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('添加服务商')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +const setValue = (el, value) => { + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); +}; +const byPlaceholder = (placeholder) => [...document.querySelectorAll('input,textarea')] + .find((node) => node.getAttribute('placeholder') === placeholder); +setValue(byPlaceholder('https://api.example.com/anthropic'), '${UPSTREAM_URL}/anthropic'); +setValue(byPlaceholder('sk-...'), 'ui-test-key'); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === '添加' && !node.disabled) + ?.click(); +" +"${AB[@]}" wait 700 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('设为默认')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').includes('通用')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === '中') + ?.click(); +const thinking = [...document.querySelectorAll('input[type=\"checkbox\"]')] + .find((node) => node.getAttribute('aria-label') === '启用思考模式'); +if (thinking?.checked) thinking.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval "document.querySelector('button[aria-label=\"Close 设置\"]')?.click();" +"${AB[@]}" wait 300 >/dev/null +"${AB[@]}" fill textarea "Say ok" >/dev/null +"${AB[@]}" eval "[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('运行') && !node.disabled)?.click();" >/dev/null +"${AB[@]}" wait 5000 >/dev/null + +node --input-type=module < capture.url.includes('/anthropic/v1/messages?beta=true')) +if (!main) throw new Error('No DeepSeek Anthropic UI request captured') +if (main.body?.thinking?.type !== 'disabled') throw new Error('DeepSeek request did not disable thinking') +if (main.body?.output_config?.effort !== undefined) { + throw new Error('DeepSeek request still sends output_config.effort with disabled thinking') +} +NODE + +ui_eval " +const buttonByName = (name) => [...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === name); +buttonByName('设置')?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('添加服务商')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === 'Custom') + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('Anthropic Messages')) + ?.click(); +" +"${AB[@]}" wait 100 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('OpenAI Chat Completions')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +const setValue = (el, value) => { + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); +}; +const byPlaceholder = (placeholder) => [...document.querySelectorAll('input,textarea')] + .find((node) => node.getAttribute('placeholder') === placeholder); +setValue(byPlaceholder('服务商名称'), 'Custom OpenAI DeepSeek'); +setValue(byPlaceholder('https://api.example.com/anthropic'), '${UPSTREAM_URL}'); +setValue(byPlaceholder('sk-...'), 'ui-test-key'); +setValue(byPlaceholder('Model ID'), 'deepseek-v4-pro'); +const sameAsMain = [...document.querySelectorAll('input')] + .filter((node) => node.getAttribute('placeholder') === '与主模型相同'); +setValue(sameAsMain[0], 'deepseek-v4-flash'); +setValue(sameAsMain[1], 'deepseek-v4-pro'); +setValue(sameAsMain[2], 'deepseek-v4-pro'); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === '添加' && !node.disabled) + ?.click(); +" +"${AB[@]}" wait 700 >/dev/null +ui_eval " +const buttons = [...document.querySelectorAll('button')] + .filter((node) => (node.textContent || '').includes('设为默认')); +buttons.at(-1)?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval "document.querySelector('button[aria-label=\"Close 设置\"]')?.click();" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === '新建会话') + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +"${AB[@]}" fill textarea "Say ok through custom openai" >/dev/null +"${AB[@]}" eval "[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('运行') && !node.disabled)?.click();" >/dev/null +"${AB[@]}" wait 5000 >/dev/null + +node --input-type=module < capture.url.includes('/v1/chat/completions')) +if (!openai) throw new Error('No Custom OpenAI Chat UI request captured') +if (openai.body?.thinking !== undefined) throw new Error('OpenAI Chat request leaked Anthropic thinking') +if (openai.body?.output_config !== undefined) throw new Error('OpenAI Chat request leaked Anthropic output_config') +if (openai.body?.reasoning_effort !== undefined) throw new Error('OpenAI Chat request unexpectedly sent reasoning_effort') +NODE + +echo "UI E2E passed. Artifacts: ${ARTIFACT_DIR}" diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 3e567955..fada8f82 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1557,17 +1557,28 @@ async function* queryModel( : [] const extraBodyParams = getExtraBodyParams(bedrockBetas) + const hasThinking = + thinkingConfig.type !== 'disabled' && + !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING) + const modelCanThink = modelSupportsThinking(options.model) + const sendsExplicitDisabledThinking = + !(hasThinking && modelCanThink) && shouldSendExplicitDisabledThinking() + const outputConfig: BetaOutputConfig = { ...((extraBodyParams.output_config as BetaOutputConfig) ?? {}), } - configureEffortParams( - effort, - outputConfig, - extraBodyParams, - betasParams, - options.model, - ) + if (sendsExplicitDisabledThinking) { + delete outputConfig.effort + } else { + configureEffortParams( + effort, + outputConfig, + extraBodyParams, + betasParams, + options.model, + ) + } configureTaskBudgetParams( options.taskBudget, @@ -1594,15 +1605,12 @@ async function* queryModel( options.maxOutputTokensOverride || getMaxOutputTokensForModel(options.model) - const hasThinking = - thinkingConfig.type !== 'disabled' && - !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING) let thinking: BetaMessageStreamParams['thinking'] | undefined = undefined // IMPORTANT: Do not change the adaptive-vs-budget thinking selection below // without notifying the model launch DRI and research. This is a sensitive // setting that can greatly affect model quality and bashing. - if (hasThinking && modelSupportsThinking(options.model)) { + if (hasThinking && modelCanThink) { if ( !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING) && modelSupportsAdaptiveThinking(options.model) @@ -1628,7 +1636,7 @@ async function* queryModel( type: 'enabled', } satisfies BetaMessageStreamParams['thinking'] } - } else if (shouldSendExplicitDisabledThinking()) { + } else if (sendsExplicitDisabledThinking) { thinking = { type: 'disabled', } as unknown as BetaMessageStreamParams['thinking']