From 8211268bd89b0adccc2da7f4f749102aa7b81119 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Tue, 28 Jul 2026 09:25:02 +0800 Subject: [PATCH] fix(text,mcp): keep thinking_budget on enable_thinking retry and hint MCP activation on 404 --- docs/agents/url-change.md | 3 +- .../src/commands/mcp/activate-hint.ts | 39 +++++++++ packages/commands/src/commands/mcp/call.ts | 22 +++-- packages/commands/src/commands/mcp/tools.ts | 14 +++- .../src/commands/search/web-activate-hint.ts | 38 +++------ packages/commands/src/commands/text/chat.ts | 12 +-- .../commands/tests/mcp-activate-hint.test.ts | 81 +++++++++++++++++++ packages/core/src/models/index.ts | 1 + packages/core/src/models/thinking.ts | 12 +++ packages/core/tests/thinking.test.ts | 38 +++++++++ packages/runtime/src/index.ts | 1 + packages/runtime/src/urls.ts | 7 +- 12 files changed, 223 insertions(+), 45 deletions(-) create mode 100644 packages/commands/src/commands/mcp/activate-hint.ts create mode 100644 packages/commands/tests/mcp-activate-hint.test.ts diff --git a/docs/agents/url-change.md b/docs/agents/url-change.md index 515e3a4..25a7ea8 100644 --- a/docs/agents/url-change.md +++ b/docs/agents/url-change.md @@ -20,7 +20,8 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only) BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview - MCP_WEBSEARCH_PAGE BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/WebSearch + MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch") + mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/ core/files/upload.ts ← 文件上传 endpoint(cn-pinned) UPLOAD_API ${REGIONS.cn}/api/v1/uploads diff --git a/packages/commands/src/commands/mcp/activate-hint.ts b/packages/commands/src/commands/mcp/activate-hint.ts new file mode 100644 index 0000000..ff7e11c --- /dev/null +++ b/packages/commands/src/commands/mcp/activate-hint.ts @@ -0,0 +1,39 @@ +import { BailianError } from "bailian-cli-core"; +import { mcpMarketplaceDetailPage } from "bailian-cli-runtime"; + +/** Detect MCP-not-activated / invalid 404 errors (CLI-wrapped server message). */ +export function isMcpNotActivated(error: unknown): boolean { + if (!(error instanceof BailianError)) return false; + const message = error.message; + if (!/MCP request failed:\s*404\b/i.test(message)) return false; + return /未开通|MCP不存在|MCP_IS_INVALID/i.test(message); +} + +/** Activation hint; URL from runtime/urls.ts. */ +export function mcpActivateHint(serverCode: string): string { + const lines = [ + `Activate (or re-activate) the ${serverCode} MCP in the Bailian MCP marketplace, then retry.`, + ]; + if (serverCode === "WebSearch") { + lines.push( + "If it was previously on SSE, cancel and activate again to upgrade to Streamable HTTP.", + ); + } + lines.push(`Open: ${mcpMarketplaceDetailPage(serverCode)}`); + return lines.join("\n"); +} + +/** + * For not-activated errors, keep the original message / exitCode and append a hint only. + * Do not replace the server error message. + */ +export function rethrowWithMcpActivateHint(error: unknown, serverCode: string): never { + if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) { + throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), { + cause: error, + api: error.api, + rawResponse: error.rawResponse, + }); + } + throw error; +} diff --git a/packages/commands/src/commands/mcp/call.ts b/packages/commands/src/commands/mcp/call.ts index b0517cd..3d6ba0b 100644 --- a/packages/commands/src/commands/mcp/call.ts +++ b/packages/commands/src/commands/mcp/call.ts @@ -8,6 +8,7 @@ import { type ParsedFlags, } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; +import { rethrowWithMcpActivateHint } from "./activate-hint.ts"; const CALL_FLAGS = { target: { @@ -130,14 +131,21 @@ export default defineCommand({ } const client = ctx.client.mcp(url); - await client.initialize(); - const result = await client.callTool(toolName, toolArgs); + try { + await client.initialize(); + const result = await client.callTool(toolName, toolArgs); - if (result.isError) { - const errText = result.content.map((c) => c.text || "").join("\n"); - throw new BailianError(`Tool error: ${errText}`); + if (result.isError) { + const errText = result.content.map((c) => c.text || "").join("\n"); + throw new BailianError(`Tool error: ${errText}`); + } + + emitResult(result, format); + } catch (error) { + if (!flags.url) { + rethrowWithMcpActivateHint(error, serverCode); + } + throw error; } - - emitResult(result, format); }, }); diff --git a/packages/commands/src/commands/mcp/tools.ts b/packages/commands/src/commands/mcp/tools.ts index bc69128..fc38f42 100644 --- a/packages/commands/src/commands/mcp/tools.ts +++ b/packages/commands/src/commands/mcp/tools.ts @@ -1,5 +1,6 @@ import { defineCommand, bailianMcpPath, detectOutputFormat } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; +import { rethrowWithMcpActivateHint } from "./activate-hint.ts"; export default defineCommand({ description: "List tools exposed by an MCP server (tools/list)", @@ -36,8 +37,15 @@ export default defineCommand({ } const client = ctx.client.mcp(url); - await client.initialize(); - const tools = await client.listTools(); - emitResult({ server: code, url, tools }, format); + try { + await client.initialize(); + const tools = await client.listTools(); + emitResult({ server: code, url, tools }, format); + } catch (error) { + if (!flags.url) { + rethrowWithMcpActivateHint(error, code); + } + throw error; + } }, }); diff --git a/packages/commands/src/commands/search/web-activate-hint.ts b/packages/commands/src/commands/search/web-activate-hint.ts index 6f5d93e..cbf6c7e 100644 --- a/packages/commands/src/commands/search/web-activate-hint.ts +++ b/packages/commands/src/commands/search/web-activate-hint.ts @@ -1,34 +1,18 @@ -import { BailianError } from "bailian-cli-core"; -import { MCP_WEBSEARCH_PAGE } from "bailian-cli-runtime"; +import { + isMcpNotActivated, + mcpActivateHint, + rethrowWithMcpActivateHint, +} from "../mcp/activate-hint.ts"; -/** recoginze WebSearch MCP not activated / invalid caused 404 (CLI wrapped message from server)。 */ -export function isWebSearchMcpNotActivated(error: unknown): boolean { - if (!(error instanceof BailianError)) return false; - const message = error.message; - if (!/MCP request failed:\s*404\b/i.test(message)) return false; - return /未开通|MCP不存在|MCP_IS_INVALID/i.test(message); -} +/** Detect WebSearch MCP not-activated / invalid 404 errors. */ +export const isWebSearchMcpNotActivated = isMcpNotActivated; -/** activate hint; URL from runtime/urls.ts。 */ +/** WebSearch activation hint. */ export function webSearchActivateHint(): string { - return [ - "Activate (or re-activate) the WebSearch MCP in the Bailian MCP marketplace, then retry.", - "If it was previously on SSE, cancel and activate again to upgrade to Streamable HTTP.", - `Open: ${MCP_WEBSEARCH_PAGE}`, - ].join("\n"); + return mcpActivateHint("WebSearch"); } -/** - * keep original message / exitCode for not activated errors, add hint only; other errors throw as is. - * do not replace server error message. - */ +/** Keep the original message; append a hint for WebSearch not-activated errors. */ export function rethrowWithWebSearchActivateHint(error: unknown): never { - if (isWebSearchMcpNotActivated(error) && error instanceof BailianError && !error.hint) { - throw new BailianError(error.message, error.exitCode, webSearchActivateHint(), { - cause: error, - api: error.api, - rawResponse: error.rawResponse, - }); - } - throw error; + rethrowWithMcpActivateHint(error, "WebSearch"); } diff --git a/packages/commands/src/commands/text/chat.ts b/packages/commands/src/commands/text/chat.ts index 6117fa4..1107c75 100644 --- a/packages/commands/src/commands/text/chat.ts +++ b/packages/commands/src/commands/text/chat.ts @@ -4,7 +4,7 @@ import { parseSSE, detectOutputFormat, readTextFromPathOrStdin, - applyChatEnableThinking, + applyChatEnableThinkingWithBudget, resolveChatEnableThinking, withEnableThinkingRetry, type ChatMessage, @@ -152,10 +152,10 @@ export default defineCommand({ enableThinking: flags.enableThinking, stream: shouldStream, }); - applyChatEnableThinking(body, enableThinking); - if (enableThinking === true && flags.thinkingBudget !== undefined) { - body.thinking_budget = flags.thinkingBudget; - } + const applyThinking = (value: boolean | undefined) => { + applyChatEnableThinkingWithBudget(body, value, flags.thinkingBudget); + }; + applyThinking(enableThinking); if (flags.tool) { const tools = flags.tool.map((t) => { @@ -232,7 +232,7 @@ export default defineCommand({ } else { const response = await withEnableThinkingRetry({ initial: enableThinking, - apply: (value) => applyChatEnableThinking(body, value), + apply: applyThinking, run: () => ctx.client.requestJson({ path: chatPath(), diff --git a/packages/commands/tests/mcp-activate-hint.test.ts b/packages/commands/tests/mcp-activate-hint.test.ts new file mode 100644 index 0000000..823a6b8 --- /dev/null +++ b/packages/commands/tests/mcp-activate-hint.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "vite-plus/test"; +import { BailianError, ExitCode } from "bailian-cli-core"; +import { mcpMarketplaceDetailPage } from "bailian-cli-runtime"; +import { + isMcpNotActivated, + mcpActivateHint, + rethrowWithMcpActivateHint, +} from "../src/commands/mcp/activate-hint.ts"; + +describe("mcp-activate-hint", () => { + test("识别 404 + 未开通 / MCP不存在 / MCP_IS_INVALID", () => { + expect( + isMcpNotActivated(new BailianError("MCP request failed: 404 Not Found - MCP不存在或未开通")), + ).toBe(true); + expect( + isMcpNotActivated(new BailianError("MCP request failed: 404 - MCP不存在或未开通")), + ).toBe(true); + expect( + isMcpNotActivated(new BailianError("MCP request failed: 404 Not Found - MCP_IS_INVALID")), + ).toBe(true); + }); + + test("裸 404 或非 MCP 错误不加开通判定", () => { + expect(isMcpNotActivated(new BailianError("MCP request failed: 404 Not Found"))).toBe(false); + expect(isMcpNotActivated(new BailianError("MCP request failed: 405 Method Not Allowed"))).toBe( + false, + ); + expect(isMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false); + }); + + test("hint 含对应 server 的 MCP 广场深链", () => { + const serverCode = "market-cmapi00073529"; + expect(mcpActivateHint(serverCode)).toContain(mcpMarketplaceDetailPage(serverCode)); + expect(mcpActivateHint(serverCode)).toMatch(/Activate|re-activate/i); + }); + + test("WebSearch hint 含 SSE 升级说明", () => { + expect(mcpActivateHint("WebSearch")).toMatch(/SSE|Streamable HTTP/i); + }); + + test("rethrow 保留原 message,补 hint", () => { + const serverCode = "market-cmapi00073529"; + const original = new BailianError( + "MCP request failed: 404 Not Found - MCP不存在或未开通", + ExitCode.GENERAL, + ); + try { + rethrowWithMcpActivateHint(original, serverCode); + expect.unreachable("should throw"); + } catch (error) { + expect(error).toBeInstanceOf(BailianError); + const wrapped = error as BailianError; + expect(wrapped.message).toBe(original.message); + expect(wrapped.exitCode).toBe(ExitCode.GENERAL); + expect(wrapped.hint).toContain(mcpMarketplaceDetailPage(serverCode)); + expect(wrapped.cause).toBe(original); + } + }); + + test("已有 hint 或非未开通错误原样抛出", () => { + const withHint = new BailianError( + "MCP request failed: 404 Not Found - MCP不存在或未开通", + ExitCode.GENERAL, + "already hinted", + ); + try { + rethrowWithMcpActivateHint(withHint, "WebSearch"); + expect.unreachable("should throw"); + } catch (error) { + expect(error).toBe(withHint); + } + + const other = new BailianError("MCP request failed: 401 Unauthorized"); + try { + rethrowWithMcpActivateHint(other, "WebSearch"); + expect.unreachable("should throw"); + } catch (error) { + expect(error).toBe(other); + } + }); +}); diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 4af5e3d..7fbcca2 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -1,6 +1,7 @@ export { adjustEnableThinkingAfterError, applyChatEnableThinking, + applyChatEnableThinkingWithBudget, resolveChatEnableThinking, withEnableThinkingRetry, type EnableThinkingAdjustResult, diff --git a/packages/core/src/models/thinking.ts b/packages/core/src/models/thinking.ts index a5d25b1..2334b75 100644 --- a/packages/core/src/models/thinking.ts +++ b/packages/core/src/models/thinking.ts @@ -53,6 +53,18 @@ export function applyChatEnableThinking( body.enable_thinking = true; } +/** Set `enable_thinking` and optionally write `thinking_budget` when enabled. */ +export function applyChatEnableThinkingWithBudget( + body: { enable_thinking?: boolean; thinking_budget?: number }, + value: boolean | undefined, + thinkingBudget?: number, +): void { + applyChatEnableThinking(body, value); + if (value === true && thinkingBudget !== undefined) { + body.thinking_budget = thinkingBudget; + } +} + function errorMessageOf(error: unknown): string { if (error instanceof Error) return error.message; return String(error); diff --git a/packages/core/tests/thinking.test.ts b/packages/core/tests/thinking.test.ts index 547acf4..9977f2a 100644 --- a/packages/core/tests/thinking.test.ts +++ b/packages/core/tests/thinking.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "vite-plus/test"; import { adjustEnableThinkingAfterError, applyChatEnableThinking, + applyChatEnableThinkingWithBudget, resolveChatEnableThinking, withEnableThinkingRetry, } from "../src/models/thinking.ts"; @@ -123,6 +124,43 @@ test("withEnableThinkingRetry:must-be-false 时从 omit 重试为 false", asyn expect(values).toEqual([undefined, false]); }); +test("applyChatEnableThinkingWithBudget:仅在 enable_thinking=true 时写入 budget", () => { + const body: { enable_thinking?: boolean; thinking_budget?: number } = {}; + applyChatEnableThinkingWithBudget(body, false, 2048); + expect(body.enable_thinking).toBe(false); + expect(body).not.toHaveProperty("thinking_budget"); + + applyChatEnableThinkingWithBudget(body, undefined, 2048); + expect(body).not.toHaveProperty("enable_thinking"); + expect(body).not.toHaveProperty("thinking_budget"); + + applyChatEnableThinkingWithBudget(body, true, 2048); + expect(body.enable_thinking).toBe(true); + expect(body.thinking_budget).toBe(2048); +}); + +test("withEnableThinkingRetry:restricted-to-true 重试时保留 thinking_budget", async () => { + const body: { enable_thinking?: boolean; thinking_budget?: number } = {}; + let calls = 0; + + const result = await withEnableThinkingRetry({ + initial: false, + apply: (value) => applyChatEnableThinkingWithBudget(body, value, 2048), + run: async () => { + calls += 1; + if (calls === 1) { + throw new Error("The value of the enable_thinking parameter is restricted to True."); + } + return "ok"; + }, + }); + + expect(result).toBe("ok"); + expect(calls).toBe(2); + expect(body.enable_thinking).toBe(true); + expect(body.thinking_budget).toBe(2048); +}); + test("withEnableThinkingRetry:无关错误原样抛出", async () => { await expect( withEnableThinkingRetry({ diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 7556c7a..83c1318 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -35,6 +35,7 @@ export { API_KEY_PAGE, TOKEN_PLAN_PAGE, MCP_WEBSEARCH_PAGE, + mcpMarketplaceDetailPage, VOICE_TTS_PAGE, } from "./urls.ts"; diff --git a/packages/runtime/src/urls.ts b/packages/runtime/src/urls.ts index c230fe0..16ada5c 100644 --- a/packages/runtime/src/urls.ts +++ b/packages/runtime/src/urls.ts @@ -18,11 +18,16 @@ export const API_KEY_PAGE = `${BAILIAN_CONSOLE}/?tab=app#/api-key`; /** Direct deep link to the Token Plan subscription overview and API key entry. */ export const TOKEN_PLAN_PAGE = `${BAILIAN_CONSOLE_ROOT}/cn-beijing?tab=plan#/efm/subscription/overview`; +/** MCP marketplace detail page for a server code (e.g. WebSearch, market-cmapi00073529). */ +export function mcpMarketplaceDetailPage(serverCode: string): string { + return `${BAILIAN_CONSOLE}?tab=mcp#/mcp-market/detail/${serverCode}`; +} + /** * MCP marketplace detail for the built-in WebSearch server. * Users must activate (or re-activate for Streamable HTTP) before `search web` works. */ -export const MCP_WEBSEARCH_PAGE = `${BAILIAN_CONSOLE}?tab=mcp#/mcp-market/detail/WebSearch`; +export const MCP_WEBSEARCH_PAGE = mcpMarketplaceDetailPage("WebSearch"); /** Voice TTS experience center — browse system and custom voices. */ export const VOICE_TTS_PAGE = "https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list";