From cc4446eeccad6abcbf9a83869b5e1fc72bee1c36 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 24 Oct 2026 21:51:28 +0800 Subject: [PATCH 1/4] fix(mcp): keep resource-listing diagnostics off ACP stdout The MCP SDK answers resources/list and resources/templates/list for a server without the resources capability with an empty page, but first writes a console.debug line to stdout. Under the ACP profile that line lands in the JSON-RPC stream whenever the model lists resources of a client-supplied, tools-only MCP server. Return the same empty pages before calling the SDK, and cover it with a unit assertion and an ACP stdout regression test. --- .../profiles/acp/tests/stdout.expected.e2e.ts | 66 +++++++++++++++++++ packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 2 +- packages/mcp/mcp-client/README.zh.md | 2 +- packages/mcp/mcp-client/src/connection.ts | 19 ++++-- .../mcp/mcp-client/tests/protocol.spec.ts | 4 ++ packages/mcp/mcp-resources/README.i18n.yaml | 4 +- packages/mcp/mcp-resources/README.md | 2 +- packages/mcp/mcp-resources/README.zh.md | 2 +- 9 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts diff --git a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts new file mode 100644 index 0000000000..41ebdbe1f7 --- /dev/null +++ b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts @@ -0,0 +1,66 @@ +/** MCP resource-listing diagnostics preserve the shipped ACP profile's JSON-RPC stream. */ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { launchAcpTestAgent, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot' +import { expect, it } from 'vitest' +import { cleanupAcpExampleTest } from './cleanup.ts' + +const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) +const binScript = join(repoRoot, 'apps/cli/src/bin.ts') +const tsconfigPath = join(repoRoot, 'tsconfig.json') + +/** Parse every stdout line as a JSON-RPC frame; a diagnostic line fails the parse with its leading text. */ +function jsonRpcFrames(launched: LaunchedAcpTestAgent): Record[] { + const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record) + for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' }) + return frames +} + +it('keeps MCP resource-listing diagnostics off stdout for a tools-only client server', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) + const llm = await startMockLlmServer({ + sequence: ['tool_call_success', 'success'], + toolName: 'list_mcp_resources', + toolArguments: JSON.stringify({ server: 'fixture' }), + }) + let launched: LaunchedAcpTestAgent | undefined + try { + const configPath = join(cwd, 'mcp.patch.yml') + await writeFile(configPath, JSON.stringify([ + { id: 'llm-deepseek', config: { baseURL: llm.baseURL, models: [{ id: 'fixture-api-key-model' }] } }, + { id: 'acp', config: { provider: 'deepseek-official', model: 'fixture-api-key-model' } }, + { id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } }, + ]) + '\n') + launched = launchAcpTestAgent({ + agent: { binScript, configPath, profile: 'acp', tsconfigPath }, + cwd, + env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: llm.baseURL, DSH_TELEMETRY_DISABLED: '1' }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + // The fixture server advertises only the tools capability. + const mcpServer = join(repoRoot, 'packages/mcp/mcp-client/tests/fixture-server.ts') + const created = await launched.client.newSession({ + cwd, mcpServers: [{ name: 'fixture', command: process.execPath, args: [mcpServer], env: [] }], + }) + await expect(launched.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'list the fixture resources' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(launched.updates).toContainEqual(expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed' })) + await launched.client.closeSession({ sessionId: created.sessionId }) + await launched.close() + + expect(launched.child.exitCode).toBe(0) + expect(llm.requests.map(request => request.behavior)).toEqual(['tool_call_success', 'success']) + expect(jsonRpcFrames(launched).length).toBeGreaterThanOrEqual(5) + } finally { + const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd), llm.close()]) + const failures: unknown[] = [] + for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason) + if (failures.length > 0) throw new AggregateError(failures, 'ACP MCP test cleanup failed') + } +}) diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 9a88686b46..8503666317 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -42,8 +42,8 @@ en: ae9029b48b2a2e4e zh: 048f08361b799667 /deepseek-ai-dsh-mcp-client/understand-the-implementation/tool-execution-internals: - en: b4333df1bd3f027c - zh: 6bb4be691452c27a + en: bb161208cc002746 + zh: 3ab0588ca71effe5 /deepseek-ai-dsh-mcp-client/understand-the-implementation/environment-scrubbing-stdio: en: fef482a6f8cc789f zh: 08f4c594ec83b4a1 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 950b9b8c85..be3f250ebf 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -130,7 +130,7 @@ The SDK receives tool-list changes through legacy notifications or a modern subs ### Tool execution internals -A tool call uses the SDK with the raw name, complete tool definition, JSON arguments, abort signal, and configured timeout. The SDK owns protocol validation, advertised output-schema validation, and modern request headers. Canonical success is `{ content: JsonValue[], structuredContent? }`, preserving valid MCP JSON blocks for programmatic and PTC mode callers. An MCP `isError` result throws before image persistence. The bridge validates each image batch before saving it; a refusal projects every image as diagnostic text. +A tool call uses the SDK with the raw name, complete tool definition, JSON arguments, abort signal, and configured timeout. The SDK owns protocol validation, advertised output-schema validation, and modern request headers. Canonical success is `{ content: JsonValue[], structuredContent? }`, preserving valid MCP JSON blocks for programmatic and PTC mode callers. An MCP `isError` result throws before image persistence. The bridge validates each image batch before saving it; a refusal projects every image as diagnostic text. For a server that does not advertise `resources`, resource and template lists return empty pages without calling the SDK, whose fallback writes a diagnostic to stdout, where the ACP profile carries JSON-RPC. ### Environment scrubbing (stdio) diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index 3ba2038418..00612777e4 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -130,7 +130,7 @@ SDK 通过旧版通知或现代协议订阅接收工具列表变化。监督器 ### 工具执行内部细节 -工具调用向 SDK 提供原始名称、完整工具定义、JSON 参数、取消信号及配置的超时。SDK 负责协议校验、已声明输出 schema 的校验和现代协议请求 header。成功结果规范值为 `{ content: JsonValue[], structuredContent? }`,为编程调用方及 PTC 模式保留有效的 MCP JSON 块。MCP `isError` 结果会在图片持久化前抛出。桥接器在保存前校验整批图片;拒绝时将每张图片投影为诊断文本。 +工具调用向 SDK 提供原始名称、完整工具定义、JSON 参数、取消信号及配置的超时。SDK 负责协议校验、已声明输出 schema 的校验和现代协议请求 header。成功结果规范值为 `{ content: JsonValue[], structuredContent? }`,为编程调用方及 PTC 模式保留有效的 MCP JSON 块。MCP `isError` 结果会在图片持久化前抛出。桥接器在保存前校验整批图片;拒绝时将每张图片投影为诊断文本。对未声明 `resources` 能力的服务器,资源列表与模板列表直接返回空页而不调用 SDK,因为 SDK 的兜底逻辑会把诊断写到 stdout,而 ACP 配置档在 stdout 上传输 JSON-RPC。 ### 环境清洗(stdio) diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts index 9a4ec9123a..310925f184 100644 --- a/packages/mcp/mcp-client/src/connection.ts +++ b/packages/mcp/mcp-client/src/connection.ts @@ -368,15 +368,22 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe const generation = client if (!generation || connectedAt === undefined) throw new Error(`${label}: server is disconnected`) const options = { signal: exec.signal, timeout: config.toolCallTimeoutMs } + // The SDK answers an unadvertised capability with the same empty page, but + // writes a console.debug line to stdout, which the ACP profile reserves for JSON-RPC. + const listable = generation.getServerCapabilities()?.resources !== undefined switch (request.method) { case 'resources/list': - return await generation.listResources( - request.cursor === undefined ? undefined : { cursor: request.cursor }, options, - ) as JsonValue + return listable + ? await generation.listResources( + request.cursor === undefined ? undefined : { cursor: request.cursor }, options, + ) as JsonValue + : { resources: [] } case 'resources/templates/list': - return await generation.listResourceTemplates( - request.cursor === undefined ? undefined : { cursor: request.cursor }, options, - ) as JsonValue + return listable + ? await generation.listResourceTemplates( + request.cursor === undefined ? undefined : { cursor: request.cursor }, options, + ) as JsonValue + : { resourceTemplates: [] } case 'resources/read': return await generation.readResource({ uri: request.uri }, options) as JsonValue /* v8 ignore next 2 -- resource requests are the closed, typed tool operation union */ diff --git a/packages/mcp/mcp-client/tests/protocol.spec.ts b/packages/mcp/mcp-client/tests/protocol.spec.ts index 6124752c08..5ec78b6573 100644 --- a/packages/mcp/mcp-client/tests/protocol.spec.ts +++ b/packages/mcp/mcp-client/tests/protocol.spec.ts @@ -62,6 +62,8 @@ describe('modern MCP connections', () => { expect(names.toSorted()).toEqual([ 'list_mcp_resource_templates', 'list_mcp_resources', 'mcp__fixture__ping', 'read_mcp_resource', ]) + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}) + onTestFinished(() => { debug.mockRestore() }) for (const [name, expected] of [ ['list_mcp_resources', { resources: [] }], ['list_mcp_resource_templates', { resourceTemplates: [] }], @@ -72,6 +74,8 @@ describe('modern MCP connections', () => { }) expect(result).toMatchObject({ isError: false, value: expected }) } + // console.debug writes stdout, which the ACP profile reserves for JSON-RPC. + expect(debug).not.toHaveBeenCalled() const read = await ctx.tools.execute({ name: 'read_mcp_resource', arguments: { server: 'fixture', uri: 'memo://readme' }, callId: ToolCallId('unsupported-resource-read'), signal: new AbortController().signal, diff --git a/packages/mcp/mcp-resources/README.i18n.yaml b/packages/mcp/mcp-resources/README.i18n.yaml index 67fdbaba79..97cb1f5a8b 100644 --- a/packages/mcp/mcp-resources/README.i18n.yaml +++ b/packages/mcp/mcp-resources/README.i18n.yaml @@ -57,8 +57,8 @@ en: ea0dfc069bf175c0 zh: 3e1465e56ff79764 /deepseek-ai-dsh-mcp-resources/known-limitations-and-deferred-work: - en: 58d13dc99946c11d - zh: 4099c46a1ca60512 + en: 73f1775fd13a7e98 + zh: 8501425c7dd12aee /deepseek-ai-dsh-mcp-resources/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/mcp/mcp-resources/README.md b/packages/mcp/mcp-resources/README.md index 1380095a38..281a8feffb 100644 --- a/packages/mcp/mcp-resources/README.md +++ b/packages/mcp/mcp-resources/README.md @@ -117,7 +117,7 @@ Each result appends to history without rewriting earlier results. Later reads ca Resource access is explicit and on demand. -- A configured server without the MCP `resources` capability still appears in the server-name prompt and keeps shared resource tools available. The SDK returns empty resource and template lists; unsupported reads fail. +- A configured server without the MCP `resources` capability still appears in the server-name prompt and keeps shared resource tools available. The MCP client returns empty resource and template lists without contacting the server; unsupported reads fail. - `tools.restrict()` checks names supplied by global or ancestor scopes when the filter is registered. Naming a resource tool absent from those scopes fails as an unknown tool. Resource tools registered in the caller's own scope are outside allow/deny masks. - Resource subscriptions and update notifications are unsupported; call the list or read tools again to obtain current content. - Binary resources are not projected as native images or audio. Programmatic callers retain their canonical base64 values. diff --git a/packages/mcp/mcp-resources/README.zh.md b/packages/mcp/mcp-resources/README.zh.md index c9a5f2d4af..d6baa73bcc 100644 --- a/packages/mcp/mcp-resources/README.zh.md +++ b/packages/mcp/mcp-resources/README.zh.md @@ -117,7 +117,7 @@ kind: "package-reference" 资源访问由显式调用按需发起。 -- 不具备 MCP `resources` 能力的已配置服务器仍会出现在服务器名称提示词中,并保持共享资源工具可用。SDK 返回空的资源列表与模板列表;不受支持的读取会失败。 +- 不具备 MCP `resources` 能力的已配置服务器仍会出现在服务器名称提示词中,并保持共享资源工具可用。MCP 客户端直接返回空的资源列表与模板列表,不联系服务器;不受支持的读取会失败。 - `tools.restrict()` 在注册过滤条件时检查全局或祖先作用域提供的名称。引用这些作用域中不存在的资源工具名称会报未知工具错误。注册在调用方自身作用域中的资源工具不受 allow/deny 掩码过滤。 - 不支持资源订阅与更新通知;再次调用列表或读取工具以获取当前内容。 - 二进制资源不会投影为原生图片或音频。程序化调用方保留其规范 base64 值。 From c38265a00e01334087298f5e97f76bc62ecf63fa Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:48:52 +0800 Subject: [PATCH 2/4] fix(ui): show view tab tooltip only for overflowing labels Conversation view tabs added a tooltip to every label in 0.2.1-rc.3, including short labels that fit the 88px cell. Estimate the glyph width and only enable the tooltip when the label is expected to overflow; keep the More trigger tooltip. Refs #7181 --- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/skeleton/ConversationSession.tsx | 25 ++++++++++++++++++- .../tests/skeleton.client.spec.tsx | 25 ++++++++++++++++++- 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index dcf29e6ee2..c98deec00e 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9912b286384c41d7 zh: 3e994887c1948677 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 626f5095ffbd8018 - zh: 672a7762384f4caf + en: 4fb1c7aa82faac76 + zh: 83972b82b6ee50cc /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3abab20faf..1b4e32398b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -72,7 +72,7 @@ The context-occupancy button shows a ring and percentage below the input card, a With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility. -While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Individual tabs are not measured. At least one preferred tab remains outside, and long labels truncate with a full-name tooltip. Unregistered View ids remain in the in-memory pin list until plugin reload. +While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Each tab label estimates its text width from the glyphs; its full-name tooltip appears only when that estimate exceeds the 88px cell. At least one preferred tab remains outside. Unregistered View ids remain in the in-memory pin list until plugin reload. The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4b9510e326..5f695b7278 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -72,7 +72,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS 关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 -root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。不逐项测量标签。至少保留一个优先展示的外部标签,长标题截断并通过提示框提供全名。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。 +root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。每个标签按字符宽度估算文本宽度;只有估算结果超过 88px 单元格时,才通过提示框提供全名。至少保留一个优先展示的外部标签。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 3089b083df..896d953c9f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -43,6 +43,29 @@ function ViewsMoreMenu({ label, current, entries, onOpen, onPinnedChange, render ) } +/** Approximate 13px text advance: full-width glyphs take one em, other glyphs about half. */ +const WIDE_GLYPH = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/u +const WIDE_GLYPH_WIDTH = 13 +const NARROW_GLYPH_WIDTH = 7 +/** Mirrors the `.tabLabel` max-width in `ConversationRoot.module.css`. */ +const TAB_LABEL_MAX_WIDTH = 88 + +/** Estimate whether a View tab label exceeds its 88px cell. */ +function labelNeedsTooltip(label: string): boolean { + let width = 0 + for (const glyph of label) width += WIDE_GLYPH.test(glyph) ? WIDE_GLYPH_WIDTH : NARROW_GLYPH_WIDTH + return width > TAB_LABEL_MAX_WIDTH +} + +/** One View tab label; its full-name tooltip is offered only when the text is expected to overflow. */ +function ViewTabLabel({ label }: { readonly label: string }) { + return ( + + {label} + + ) +} + interface Breadcrumb { readonly id: SessionId readonly displayTitle: string @@ -205,7 +228,7 @@ export function ConversationSessionHeader({ className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)} onClick={() => { selectView(viewTab.id) }} > - {viewTab.label} + ))} diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index bda16c8a08..953fe0b8d7 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createContext, useContext, type ReactNode } from 'react' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import type { Context } from '@deepseek-ai/cordis' import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -735,6 +735,29 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByRole('menu')).toBeNull() }) + it('offers a View tab tooltip only when the label is expected to overflow', () => { + const b = mount(sessionSnapshotOf(), undefined, undefined, { + viewTabs: [ + { id: 'chat', label: 'Chat' }, + { id: 'short', label: '轨迹' }, + { id: 'long-cjk', label: '很长的视图名称' }, + { id: 'long-latin', label: 'A very long view name' }, + ], + headerOnly: true, + }) + const tooltipFor = (label: string): string | null => { + const span = within(b.view.getByRole('tab', { name: label })).getByText(label) + fireEvent.mouseEnter(span) + const tooltip = screen.queryByRole('tooltip', { hidden: true })?.textContent ?? null + fireEvent.mouseLeave(span) + return tooltip + } + expect(tooltipFor('Chat')).toBeNull() + expect(tooltipFor('轨迹')).toBeNull() + expect(tooltipFor('很长的视图名称')).toBe('很长的视图名称') + expect(tooltipFor('A very long view name')).toBe('A very long view name') + }) + it('reuses the width budget on View roster and selection changes without a window resize listener', () => { let width = 480 const readWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width) From 41736cdec58451dca1bcf53aa31cf9e251baf97d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:05:51 +0800 Subject: [PATCH 3/4] fix(acp): keep account catalog diagnostics off stdout --- .../profiles/acp/tests/stdout.expected.e2e.ts | 100 +++++++++++++++++- .../llm/llm-deepseek-account/README.i18n.yaml | 4 +- packages/llm/llm-deepseek-account/README.md | 2 +- .../llm/llm-deepseek-account/README.zh.md | 2 +- .../llm/llm-deepseek-account/src/index.ts | 4 +- .../tests/provider.spec.ts | 4 +- 6 files changed, 105 insertions(+), 11 deletions(-) diff --git a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts index 41ebdbe1f7..03a754738c 100644 --- a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts @@ -1,11 +1,13 @@ -/** MCP resource-listing diagnostics preserve the shipped ACP profile's JSON-RPC stream. */ -import { mkdtemp, writeFile } from 'node:fs/promises' +/** Account catalog and MCP resource-listing diagnostics keep the shipped ACP profile's stdout JSON-RPC only. */ +import { once } from 'node:events' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' -import { launchAcpTestAgent, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot' +import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot' import { expect, it } from 'vitest' import { cleanupAcpExampleTest } from './cleanup.ts' @@ -20,6 +22,98 @@ function jsonRpcFrames(launched: LaunchedAcpTestAgent): Record[ return frames } +it('keeps account model-listing diagnostics on stderr during API-key session creation', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) + const token = 'acp-catalog-fixture-token' + const requests: Array<{ method: string | undefined; path: string | undefined; token: string | string[] | undefined }> = [] + const server = createServer((request, response) => { + requests.push({ method: request.method, path: request.url, token: request.headers['x-dsh-auth-token'] }) + request.resume() + if (request.method !== 'GET' || request.url !== '/models') { + response.writeHead(500).end('unexpected request') + return + } + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ + data: [ + { + id: 'deepseek-flash', name: 'Account fixture model', input_modalities: ['text'], output_modalities: ['text'], + context_window: 1_048_576, max_output_tokens: 393_216, api_capabilities: { anthropic_messages: {} }, + }, + { id: 'deepseek-v4-pro' }, + ], + })) + }) + let launched: LaunchedAcpTestAgent | undefined + try { + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('account catalog server has no port') + const origin = `http://127.0.0.1:${address.port}` + await mkdir(join(cwd, '.dsh')) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), JSON.stringify({ + version: 1, + records: { + 'deepseek-account-platform/default': { kind: 'grant', payload: { version: 1, token, issuer: origin } }, + }, + }) + '\n', { mode: 0o600 }) + const configPath = join(cwd, 'catalog.patch.json') + await writeFile(configPath, JSON.stringify([ + { id: 'deepseek-account', config: { platformOrigin: origin, inferenceOrigin: origin, allowLoopbackHttp: true } }, + { id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic` } }, + { id: 'llm-deepseek', config: { baseURL: `${origin}/anthropic`, models: [{ id: 'fixture-api-key-model' }] } }, + // Catalog discovery requires the account service to have loaded the stored grant. + { + id: 'acp', inject: ['acpAppStartup', 'deepseekAccount'], + config: { provider: 'deepseek-official', model: 'fixture-api-key-model' }, + }, + { id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } }, + ]) + '\n') + const agent: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)), + configPath, + profile: 'acp', + tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)), + } + launched = launchAcpTestAgent({ + agent, + cwd, + env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: `${origin}/anthropic`, DSH_TELEMETRY_DISABLED: '1' }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await launched.client.newSession({ cwd, mcpServers: [] }) + expect(created.configOptions).toContainEqual(expect.objectContaining({ + id: 'model', currentValue: JSON.stringify(['deepseek-official', 'fixture-api-key-model']), + })) + await launched.client.closeSession({ sessionId: created.sessionId }) + // Closing joins both pipes; receiving a stdout response alone does not drain stderr. + await launched.close() + + expect(launched.child.signalCode).toBeNull() + expect(launched.child.exitCode).toBe(0) + expect(requests).toEqual([{ method: 'GET', path: '/models', token }]) + const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record) + expect(frames.length).toBeGreaterThanOrEqual(3) + for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' }) + expect(launched.stderr()).toContain(`[llm-deepseek-account] model listing from ${origin}/models loaded 2 model(s), skipped 0 row(s)`) + expect(launched.stderr()).toContain('[llm-deepseek-account] model "deepseek-v4-pro" declares no api_capabilities.anthropic_messages') + expect(launched.stderr()).not.toContain(token) + } finally { + const cleanup = await Promise.allSettled([launched?.close()]) + server.closeAllConnections() + cleanup.push(...await Promise.allSettled([ + server.listening ? new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) : undefined, + rm(cwd, { recursive: true, force: true }), + ])) + const failures: unknown[] = [] + for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason) + if (failures.length > 0) throw new AggregateError(failures, 'ACP catalog test cleanup failed') + } +}) + it('keeps MCP resource-listing diagnostics off stdout for a tools-only client server', async () => { const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) const llm = await startMockLlmServer({ diff --git a/packages/llm/llm-deepseek-account/README.i18n.yaml b/packages/llm/llm-deepseek-account/README.i18n.yaml index aebfbd467c..6bd168a382 100644 --- a/packages/llm/llm-deepseek-account/README.i18n.yaml +++ b/packages/llm/llm-deepseek-account/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4cf93c4bf17b3da2 /deepseek-ai-dsh-llm-deepseek-account/use-this-package: - en: d757388f7b390d3f - zh: 2945ff97a716f91d + en: b92099c600203029 + zh: 5d8e48261d873793 /deepseek-ai-dsh-llm-deepseek-account/understand-the-implementation: en: 534b344a98142d91 zh: 1291b721c8fead84 diff --git a/packages/llm/llm-deepseek-account/README.md b/packages/llm/llm-deepseek-account/README.md index 612b003387..4711eed96c 100644 --- a/packages/llm/llm-deepseek-account/README.md +++ b/packages/llm/llm-deepseek-account/README.md @@ -29,7 +29,7 @@ Only `deepseekAccount.resolveToken(url)` supplies the token; the account service The route's catalog comes from the account's `GET /models` listing at the origin of the Messages `baseURL` (`https://api.deepseek.com/models` for the official endpoint); the listing endpoint is not configurable. Listing requests carry the account token in `x-dsh-auth-token` and the harness `User-Agent`, and reject redirects. -The first access of a credential waits for the listing up to `modelsTimeoutMs`. Later accesses use the loaded listing and refresh it in the background once it is older than `modelsMaxAgeMs`. A failed or empty listing keeps the previous one; before any listing loads, the route serves the built-in catalog of the current version (`deepseek-flash` and `deepseek-v4-pro`). A listing HTTP 401 does not remove the stored login. Each loaded listing, the first failure of a failing streak with the catalog it falls back to, and each hidden listing entry are written to the console with the `[llm-deepseek-account]` tag; secrets are never logged. Each account token has its own listing, so the service can roll a model out to some accounts first. +The first access of a credential waits for the listing up to `modelsTimeoutMs`. Later accesses use the loaded listing and refresh it in the background once it is older than `modelsMaxAgeMs`. A failed or empty listing keeps the previous one; before any listing loads, the route serves the built-in catalog of the current version (`deepseek-flash` and `deepseek-v4-pro`). A listing HTTP 401 does not remove the stored login. Each loaded listing, the first failure of a failing streak with the catalog it falls back to, and each hidden listing entry are written to stderr with the `[llm-deepseek-account]` tag; stdout remains available for protocol messages, and secrets are never logged. Each account token has its own listing, so the service can roll a model out to some accounts first. A listed model is offered only when the current DeepSeek Harness version can serve it. A model that declares `client_requirements["deepseek-harness"].min_version` is offered when the current version is at least that version in semantic-version order, including prerelease tags such as `0.2.0-alpha.1` and `0.2.0-rc.1`. A prerelease orders below its release: `min_version: 0.2.1` refuses `0.2.1-alpha.2`, so a listing that admits prereleases declares the earliest one it admits, such as `0.2.1-alpha.0`. A model without that declaration is offered only when the built-in catalog knows its id; other listed models stay hidden. A request for a listed model the current version cannot serve fails before network I/O with `CLIENT_UPGRADE_REQUIRED`, which names the required and current versions, or with `MODEL_UNSUPPORTED`. Model ids the listing does not name pass through unchanged. diff --git a/packages/llm/llm-deepseek-account/README.zh.md b/packages/llm/llm-deepseek-account/README.zh.md index 89c4b3d6b5..25e6cd4bfd 100644 --- a/packages/llm/llm-deepseek-account/README.zh.md +++ b/packages/llm/llm-deepseek-account/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" 路由的目录来自账号的 `GET /models` 列表,地址为 Messages `baseURL` 的 origin 加 `/models`(官方端点即 `https://api.deepseek.com/models`),不可配置。列表请求用 `x-dsh-auth-token` 携带账号 token,带上 harness 的 `User-Agent`,并拒绝重定向。 -同一凭据的第一次访问会等待列表,最长 `modelsTimeoutMs`。之后的访问直接使用已加载的列表,列表超过 `modelsMaxAgeMs` 后在后台刷新。获取失败或得到空列表时保留上一份;还没有加载到任何列表时,路由提供当前版本内置的目录(`deepseek-flash` 与 `deepseek-v4-pro`)。列表请求返回 HTTP 401 不会清除已保存的登录。每次加载成功、连续失败中的第一次失败及其退回的目录、以及每个被隐藏的列表条目,都会以 `[llm-deepseek-account]` 标签写到控制台日志;不记录任何密钥。每个账号 token 有自己的列表,因此服务端可以先对部分账号开放某个模型。 +同一凭据的第一次访问会等待列表,最长 `modelsTimeoutMs`。之后的访问直接使用已加载的列表,列表超过 `modelsMaxAgeMs` 后在后台刷新。获取失败或得到空列表时保留上一份;还没有加载到任何列表时,路由提供当前版本内置的目录(`deepseek-flash` 与 `deepseek-v4-pro`)。列表请求返回 HTTP 401 不会清除已保存的登录。每次加载成功、连续失败中的第一次失败及其退回的目录、以及每个被隐藏的列表条目,都会以 `[llm-deepseek-account]` 标签写到 stderr;stdout 保留给协议消息,且不记录任何密钥。每个账号 token 有自己的列表,因此服务端可以先对部分账号开放某个模型。 只有当前版本的 DeepSeek Harness 能用的模型才会出现在目录里。声明了 `client_requirements["deepseek-harness"].min_version` 的模型,在当前版本号按 semver 顺序不低于该版本时提供,预发布号(如 `0.2.0-alpha.1`、`0.2.0-rc.1`)同样参与比较。预发布版本低于同号的正式版本:`min_version: 0.2.1` 会拒绝 `0.2.1-alpha.2`,因此要放行预发布版本的列表应声明它允许的最早一个,如 `0.2.1-alpha.0`。没有这个声明的模型,只有内置目录认识它的 id 时才提供;其余列出的模型保持隐藏。请求一个当前版本不能用的已列出模型时,会在网络请求之前失败:`CLIENT_UPGRADE_REQUIRED` 会写明所需版本和当前版本,否则为 `MODEL_UNSUPPORTED`。列表没有列出的模型 id 照常透传。 diff --git a/packages/llm/llm-deepseek-account/src/index.ts b/packages/llm/llm-deepseek-account/src/index.ts index 8909b26631..0bf1b55738 100644 --- a/packages/llm/llm-deepseek-account/src/index.ts +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -18,7 +18,7 @@ export const inject = ['llm'] const PROVIDER = 'deepseek-account' /** Header carrying the account token on Messages, Files, and model-listing requests. */ const TOKEN_HEADER = 'x-dsh-auth-token' -/** Console tag of listing diagnostics; product profiles mount no console exporter for `ctx.logger`. */ +/** Stderr tag of listing diagnostics; product profiles mount no console exporter for `ctx.logger`. */ const LOG_TAG = '[llm-deepseek-account]' /** Connection facts of one account operation: protocol settings plus the catalog resolved with them. */ @@ -54,7 +54,7 @@ export function apply(ctx: Context, config: Config): void { credential: async url => account()?.resolveToken(url), load: (url, token, signal) => requestModelListing(url, { [TOKEN_HEADER]: token }, signal), now: () => Date.now(), - info: (message) => { console.info(LOG_TAG, message) }, + info: (message) => { console.error(LOG_TAG, message) }, warn: (message) => { console.warn(LOG_TAG, message) }, }) let loggedListing: ModelListing | undefined diff --git a/packages/llm/llm-deepseek-account/tests/provider.spec.ts b/packages/llm/llm-deepseek-account/tests/provider.spec.ts index 6cfb5fe2d1..c3b5fd2f41 100644 --- a/packages/llm/llm-deepseek-account/tests/provider.spec.ts +++ b/packages/llm/llm-deepseek-account/tests/provider.spec.ts @@ -109,7 +109,7 @@ it('falls back to the bundled catalog when the listing fails', async () => { }) it('hides and refuses listed models this build cannot serve before any inference request', async () => { - const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {}) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const server = await listing(json({ data: [ FLASH, @@ -127,7 +127,7 @@ it('hides and refuses listed models this build cannot serve before any inference .rejects.toMatchObject({ code: 'CLIENT_UPGRADE_REQUIRED', message: expect.stringMatching(/requires DeepSeek Harness 99\.0\.0-alpha\.1 or later; current version is /) as string }) await expect(ctx.llm.prepareCall({ provider: 'deepseek-account', model: 'unlisted-pass-through' })).resolves.toBeDefined() expect(server.requests.map(request => request.url)).toEqual(['/models']) - expect(info).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-account]', expect.stringContaining('loaded 5 model(s), skipped 0 row(s)') as string) + expect(diagnostic).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-account]', expect.stringContaining('loaded 5 model(s), skipped 0 row(s)') as string) expect(warn).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-account]', 'model "deepseek-v4-pro" declares no api_capabilities.anthropic_messages') }) From 940aafcadb085295afa65c3d52be4fa2315a3b63 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:06:07 +0800 Subject: [PATCH 4/4] test(acp): use shared cleanup for the stdout regression --- apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts | 7 +++---- packages/llm/llm-deepseek-account/src/index.ts | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts index 03a754738c..c97e093237 100644 --- a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts @@ -1,6 +1,6 @@ /** Account catalog and MCP resource-listing diagnostics keep the shipped ACP profile's stdout JSON-RPC only. */ import { once } from 'node:events' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,7 +57,7 @@ it('keeps account model-listing diagnostics on stderr during API-key session cre 'deepseek-account-platform/default': { kind: 'grant', payload: { version: 1, token, issuer: origin } }, }, }) + '\n', { mode: 0o600 }) - const configPath = join(cwd, 'catalog.patch.json') + const configPath = join(cwd, 'catalog.patch.yml') await writeFile(configPath, JSON.stringify([ { id: 'deepseek-account', config: { platformOrigin: origin, inferenceOrigin: origin, allowLoopbackHttp: true } }, { id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic` } }, @@ -99,14 +99,13 @@ it('keeps account model-listing diagnostics on stderr during API-key session cre expect(launched.stderr()).toContain('[llm-deepseek-account] model "deepseek-v4-pro" declares no api_capabilities.anthropic_messages') expect(launched.stderr()).not.toContain(token) } finally { - const cleanup = await Promise.allSettled([launched?.close()]) + const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd)]) server.closeAllConnections() cleanup.push(...await Promise.allSettled([ server.listening ? new Promise((resolve, reject) => server.close((error) => { if (error === undefined) resolve() else reject(error) })) : undefined, - rm(cwd, { recursive: true, force: true }), ])) const failures: unknown[] = [] for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason) diff --git a/packages/llm/llm-deepseek-account/src/index.ts b/packages/llm/llm-deepseek-account/src/index.ts index 0bf1b55738..2ed44f794b 100644 --- a/packages/llm/llm-deepseek-account/src/index.ts +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -18,7 +18,7 @@ export const inject = ['llm'] const PROVIDER = 'deepseek-account' /** Header carrying the account token on Messages, Files, and model-listing requests. */ const TOKEN_HEADER = 'x-dsh-auth-token' -/** Stderr tag of listing diagnostics; product profiles mount no console exporter for `ctx.logger`. */ +/** Console tag of listing diagnostics; product profiles mount no console exporter for `ctx.logger`. */ const LOG_TAG = '[llm-deepseek-account]' /** Connection facts of one account operation: protocol settings plus the catalog resolved with them. */ @@ -54,6 +54,7 @@ export function apply(ctx: Context, config: Config): void { credential: async url => account()?.resolveToken(url), load: (url, token, signal) => requestModelListing(url, { [TOKEN_HEADER]: token }, signal), now: () => Date.now(), + // ACP reserves stdout for protocol messages, including during successful catalog loads. info: (message) => { console.error(LOG_TAG, message) }, warn: (message) => { console.warn(LOG_TAG, message) }, })