From 20db6ca4d5014d3314fa47e90a6c18f2768a518d Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 30 Jul 2026 19:18:59 +0800 Subject: [PATCH 01/22] feat(web): add favicon and update IconActions behavior --- ...b-message-icon-actions-and-clock.i18n.yaml | 4 +- ...7-29-web-message-icon-actions-and-clock.md | 8 ++-- ...9-web-message-icon-actions-and-clock.zh.md | 8 ++-- apps/web/index.html | 1 + apps/web/public/favicon.svg | 3 ++ apps/web/tests/message-actions.e2e.ts | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/AssistantMarkdown.tsx | 8 ++-- .../src/client/chat/ChatView.tsx | 7 +++- .../src/client/chat/chat-flow.ts | 28 ++++++++++++- .../ui-conversation/tests/chat-view.spec.tsx | 40 +++++++++++++++++-- 13 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 apps/web/public/favicon.svg diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index de8869a96a..9e9755c0b5 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: e79662056792c3ab413468ad038dec40455be767 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 72d3b4e0cda19438f2f46fd402b3b76de3726ae5 +2026-07-29-web-message-icon-actions-and-clock.md: f3a5a0caf75627f5f6b37f0e9b9e5e4b5c6ac6c3 +2026-07-29-web-message-icon-actions-and-clock.zh.md: 9c51ec931a2e45a2d19122b7dddd31d1bb9d1299 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md index e796620567..f3a5a0caf7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -10,9 +10,9 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo ## Decision -**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant *content* nodes (non-empty text blocks) append a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** +**User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each turn appends a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** -Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content; Think-only nodes and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. +Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `ChatView` derives turn-tail seqs via `assistantActionsSeqs` and withholds `time` for mid-turn content; `AssistantMarkdown` places the row after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. ## Alternatives considered @@ -20,6 +20,8 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day **Put IconActions under every finalized assistant node (including Think-only).** Rejected: copy has nothing useful to write without text content, and repeating the chrome under every step/Think row clutters the flow; only content output owns the seat. +**Put IconActions under every content-text assistant in a multi-step turn.** Rejected: mid-turn narration (text before tools) is not the settled answer; repeating copy/branch/clock under each step clutters the flow. Only the last content assistant of the turn owns the seat. + **Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate. **Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat. @@ -28,4 +30,4 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day ## Consequences -Settled assistant content answers expose copy and the event clock as soon as the row mounts; Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, and the content-only assistant gate; the web e2e scenario pins the assembled IconActions chrome. +Each turn's last settled content answer exposes copy and the event clock as soon as the row mounts; mid-turn content and Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, the content-only gate, and the turn-tail seq gate; the web e2e scenario pins the assembled IconActions chrome. diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 72d3b4e0cd..9c51ec931a 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -10,9 +10,9 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 ## 决策 -**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant *内容*节点(非空 text 块)在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** +**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;每个 turn 最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** -两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 +两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导 turn 尾部 seq,并对 turn 中间内容不传 `time`;`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、turn 中间叙述与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 ## 曾考虑的方案 @@ -20,6 +20,8 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 **给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。 +**给多步 turn 中每一条带 text 的 assistant 都挂 IconActions。** 否决:turn 中间叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有该 turn 最后一条内容 assistant 拥有该座位。 + **在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。 **把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。 @@ -28,4 +30,4 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 ## 后果 -已定稿的 assistant 内容回答在行挂载后立刻暴露复制与事件时钟;纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽与 assistant 仅内容门控;Web e2e 场景钉住组装后的 IconActions chrome。 +每个 turn 最后一条已定稿内容回答在行挂载后立刻暴露复制与事件时钟;turn 中间内容与纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、仅内容门控与 turn 尾部 seq 门控;Web e2e 场景钉住组装后的 IconActions chrome。 diff --git a/apps/web/index.html b/apps/web/index.html index fe5901f353..c9fc7d124c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,6 +3,7 @@ + DeepSeek Harness diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000000..8a8fc56752 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index aa14308d43..2f5f8f7abf 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -62,8 +62,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). User has three actions; each finalized assistant - // text node has copy + branch. + // hover/focus-within). User has three actions; each turn's last content + // assistant has copy + branch. const copyButtons = page.getByRole('button', { name: '复制' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) await copyButtons.first().focus() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1588367646..807205c03b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: fc466190a744a1c13094ca6ebf62755d5bf49c98 -README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 +README.md: 3c15fe73c242ee63b9bbc93b602653b0f5f14af6 +README.zh.md: 61718fe8bdd73054057f7ce0ad6ba7ae8ce308a1 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fc466190a7..3c15fe73c2 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ None; this package neither assembles nor sends a provider request. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub. +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free; branch remains a chrome stub. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6fbff9c1e..61718fe8bd 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个 turn 最后一条带 text 的 assistant 消息下;turn 中间叙述与纯 Think 节点不带 chrome;分支仍是 chrome stub。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 904aeee0d8..1019e2be89 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,8 +4,9 @@ // view groups them into tool rows through its keyed toolview slot (figma // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. -// Finalized content (text) nodes append IconActions once streaming ends; -// Think / tool-head-only nodes stay chrome-free. +// Finalized turn-tail content (text) nodes append IconActions once streaming +// ends (`time` is omitted for mid-turn narration); Think / tool-head-only +// nodes stay chrome-free. import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -21,7 +22,8 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */ interrupted?: boolean | undefined - /** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */ + /** Unix epoch ms for the IconActions clock; omitted while streaming or when + * the parent withholds chrome (mid-turn content assistants). */ time?: number | undefined } diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b9e1e3351f..16fa25f969 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -244,6 +244,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const selectedCallId = useStore(s => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) + // Only the last content assistant of each turn owns IconActions; mid-turn + // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. + const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -376,7 +379,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio blocks={node.blocks} streaming={false} interrupted={node.interrupted} - time={node.time} + time={actionSeqs.has(node.seq) ? node.time : undefined} /> ) } diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index f1ce061af8..ad98d3aaad 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -3,15 +3,24 @@ * results group into consecutive-run tool groups (figma step-summary flow, * VERTICAL gap10) alternating with narration; everything else passes through. * Item identity keys are stable across snapshots so the list parent can - * subscribe to keys only while rows subscribe to content. + * subscribe to keys only while rows subscribe to content. IconActions ownership + * (last content assistant per turn) is derived here too so ChatView and the + * flow share one gate. */ -import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantBlock, ConversationNode, ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' /** One renderable flow item; key is the React key and the parent's identity unit. */ export type ChatFlowItem = | { kind: 'node'; key: string; node: ConversationNode } | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } +/** True when the node has model-visible text content worth IconActions chrome. */ +function hasContentText(blocks: readonly AssistantBlock[]): boolean { + return blocks.some(block => block.kind === 'text' && block.text.trim() !== '') +} + /** An assistant node that renders nothing: only tool-call heads (rows render * via the grouping pass) and blank text/reasoning. Skipped by the flow so it * neither costs column gaps nor splits a tool-row run. Interrupted nodes @@ -22,6 +31,21 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } +/** + * Seq set of assistants that own IconActions: the last content-text assistant + * in each turn. Mid-turn narration (text before tools) stays chrome-free. + * @param nodes - snapshot nodes (surface order). + * @returns Seq values ChatView may pass as `time` into AssistantMarkdown. + */ +export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet { + const lastByTurn = new Map() + for (const node of nodes) { + if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue + lastByTurn.set(node.turn, node.seq) + } + return new Set(lastByTurn.values()) +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes (surface order). diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index c0cb4dcb78..453e6a8f0f 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -16,7 +16,7 @@ import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' -import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -61,8 +61,8 @@ const user = (seq: number, text: string): UserMessageNode => ({ content: [{ type: 'text', text }] as never, source: null, }) -const assistant = (seq: number, text: string): AssistantMessageNode => ({ - kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }], +const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], }) const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ kind: 'tool-result', seq, time: seq * 1_000, callId, @@ -154,6 +154,23 @@ describe('chat-flow derivation', () => { expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5') expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') }) + + it('assistantActionsSeqs keeps only the last content assistant per turn', () => { + const thinkOnly: AssistantMessageNode = { + kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2, + blocks: [{ kind: 'reasoning', text: 'planning' }], + } + const seqs = assistantActionsSeqs([ + user(1, 'hi'), + assistant(2, 'looking', 1), + thinkOnly, + toolResult(4, 'a'), + assistant(5, 'done', 1), + user(6, 'again'), + assistant(7, 'second turn', 2), + ]) + expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) + }) }) describe('ChatView', () => { @@ -194,6 +211,23 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) + it('shows assistant IconActions only on the last content message of each turn', () => { + const h = makeHarness({ + nodes: [ + user(1, 'hi'), + assistant(2, 'mid-turn text'), + toolResult(3, 'a'), + assistant(4, 'final answer'), + user(5, 'next'), + assistant(6, 'second turn', 2), + ], + }) + const view = render() + // 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free. + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) + expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4) + }) + it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { const markdown = '# Rendered\n\n- **one**\n- `two`' const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) From 4bf09d508c75dacf014cdf35b4bcea68b99f3f39 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 30 Jul 2026 19:35:38 +0800 Subject: [PATCH 02/22] fix: cr --- ...b-message-icon-actions-and-clock.i18n.yaml | 4 +- ...7-29-web-message-icon-actions-and-clock.md | 10 +++-- ...9-web-message-icon-actions-and-clock.zh.md | 10 +++-- apps/web/tests/message-actions.e2e.ts | 28 ++++++------- .../snapshots/message-actions/seed.jsonl | 36 ++++++++++++++++ .../snapshots/message-actions/ui.expected.md | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ChatView.tsx | 16 +++++-- .../src/client/chat/chat-flow.ts | 31 +++++++++++++- .../ui-conversation/tests/chat-view.spec.tsx | 42 ++++++++++++++++--- 12 files changed, 146 insertions(+), 40 deletions(-) create mode 100644 apps/web/tests/snapshots/message-actions/seed.jsonl diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index 9e9755c0b5..0fcd9ae778 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: f3a5a0caf75627f5f6b37f0e9b9e5e4b5c6ac6c3 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 9c51ec931a2e45a2d19122b7dddd31d1bb9d1299 +2026-07-29-web-message-icon-actions-and-clock.md: b408a3c6d6bf3e0c11764c73c0ea7a859ce0adec +2026-07-29-web-message-icon-actions-and-clock.zh.md: 4440d6cacd3d2f75d758fa00315a653507c16928 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md index f3a5a0caf7..b408a3c6d6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -10,9 +10,9 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo ## Decision -**User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each turn appends a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** +**User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each *settled* turn appends a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** -Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `ChatView` derives turn-tail seqs via `assistantActionsSeqs` and withholds `time` for mid-turn content; `AssistantMarkdown` places the row after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. +Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `ChatView` derives turn-tail seqs via `assistantActionsSeqs` and withholds a still-running turn through `withholdActionsTurn` (streaming `partial.turn`, else the first `runningCalls` turn; a bare `running` bit before the first step does not strip a prior settled seat). Selectors return a primitive turn so chunk storms do not re-render the list parent. `AssistantMarkdown` places the row after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, an active turn's content, and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (owned cold seed with mid-turn narration text + aria golden that places copy only under the user bubble and the turn-tail `DONE`); aria normalization collapses every clock shape to `{{clock}}`. ## Alternatives considered @@ -20,7 +20,9 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day **Put IconActions under every finalized assistant node (including Think-only).** Rejected: copy has nothing useful to write without text content, and repeating the chrome under every step/Think row clutters the flow; only content output owns the seat. -**Put IconActions under every content-text assistant in a multi-step turn.** Rejected: mid-turn narration (text before tools) is not the settled answer; repeating copy/branch/clock under each step clutters the flow. Only the last content assistant of the turn owns the seat. +**Put IconActions under every content-text assistant in a multi-step turn.** Rejected: mid-turn narration (text before tools) is not the settled answer; repeating copy/branch/clock under each step clutters the flow. Only the last content assistant of a settled turn owns the seat. + +**Derive the running-turn withhold from `running` plus the max turn among finalized nodes alone.** Rejected: after step-1 text lands and tools run for seconds that tip is temporarily "last content," so chrome would flash on then off; `partial` / `runningCalls` name the open turn without that flicker, and a bare `running` before the first step must leave the prior settled answer's seat alone. **Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate. @@ -30,4 +32,4 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day ## Consequences -Each turn's last settled content answer exposes copy and the event clock as soon as the row mounts; mid-turn content and Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, the content-only gate, and the turn-tail seq gate; the web e2e scenario pins the assembled IconActions chrome. +Each settled turn's last content answer exposes copy and the event clock as soon as the row mounts; mid-turn content, an active turn's content, and Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, the content-only gate, the turn-tail seq gate, and the running-turn withhold; the web e2e scenario pins the assembled IconActions chrome including mid-turn narration without a third copy control. diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 9c51ec931a..4440d6cacd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -10,9 +10,9 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 ## 决策 -**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;每个 turn 最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** +**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;每个*已结束* turn 最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** -两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导 turn 尾部 seq,并对 turn 中间内容不传 `time`;`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、turn 中间叙述与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 +两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导 turn 尾部 seq,并用 `withholdActionsTurn` 扣留仍在运行的 turn(优先流式 `partial.turn`,否则第一条 `runningCalls` 的 turn;在第一步出现前仅有 `running` 时不得撤掉上一回合已定稿答案的座位)。选择器返回原始 turn 值,因此 token 风暴不会让列表父级重渲染。`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、turn 中间叙述、活跃 turn 的内容与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(自有冷 seed,含 turn 中间叙述文本 + aria golden,仅在用户气泡与 turn 尾部 `DONE` 下放置复制)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 ## 曾考虑的方案 @@ -20,7 +20,9 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 **给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。 -**给多步 turn 中每一条带 text 的 assistant 都挂 IconActions。** 否决:turn 中间叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有该 turn 最后一条内容 assistant 拥有该座位。 +**给多步 turn 中每一条带 text 的 assistant 都挂 IconActions。** 否决:turn 中间叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有已结束 turn 的最后一条内容 assistant 拥有该座位。 + +**仅用 `running` 加上已定稿节点中的最大 turn 推导运行中 turn 的扣留。** 否决:step-1 文本落地后工具可能跑数秒,该 tip 暂时就是「最后一条 content」,chrome 会先出现再消失;`partial`/`runningCalls` 能指名开放 turn 且无此闪烁,而第一步前仅有 `running` 时必须保留上一回合已定稿答案的座位。 **在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。 @@ -30,4 +32,4 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 ## 后果 -每个 turn 最后一条已定稿内容回答在行挂载后立刻暴露复制与事件时钟;turn 中间内容与纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、仅内容门控与 turn 尾部 seq 门控;Web e2e 场景钉住组装后的 IconActions chrome。 +每个已结束 turn 的最后一条内容回答在行挂载后立刻暴露复制与事件时钟;turn 中间内容、活跃 turn 的内容与纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、仅内容门控、turn 尾部 seq 门控与运行中 turn 扣留;Web e2e 场景钉住组装后的 IconActions chrome,含 turn 中间叙述且无第三个复制控件。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 2f5f8f7abf..e9d1447e93 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -1,7 +1,8 @@ -// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history -// fixture (zero model calls) and pins the settled conversation aria after the -// user/assistant footers are focus-revealed — the surface package jsdom tests -// cannot substitute for (docs/testing.md snapshot rule). +// Web e2e scenario: message IconActions + clocks. Cold-seeds a closed turn that +// includes mid-turn narration text (so the turn-tail chrome gate is exercised) +// and pins the settled conversation aria after the user/assistant footers are +// focus-revealed — the surface package jsdom tests cannot substitute for +// (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -15,9 +16,7 @@ import { import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url)) -// Borrowed read-only: this scenario needs any settled user+assistant pair, not -// a new recording (workspace-management / sidebar-scrollbar pattern). -const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' @@ -37,7 +36,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + expect(fixtureUserPrompts(raw), 'seed must carry the drive prompt').toEqual([PROMPT]) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -51,7 +50,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { + it.skipIf(MODE === 'record')('lists the seeded session and reveals turn-tail IconActions only', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -60,15 +59,16 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText("I'll read both files.").count(), { timeout: 10_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). User has three actions; each turn's last content - // assistant has copy + branch. + // hover/focus-within). Exactly one user row + one settled turn-tail + // assistant: mid-turn narration must not add a third copy control. const copyButtons = page.getByRole('button', { name: '复制' }) - await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2) await copyButtons.first().focus() await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(2) + .toBe(2) await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1) }, 60_000) @@ -88,6 +88,6 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/message-actions/seed.jsonl b/apps/web/tests/snapshots/message-actions/seed.jsonl new file mode 100644 index 0000000000..d8d2f54463 --- /dev/null +++ b/apps/web/tests/snapshots/message-actions/seed.jsonl @@ -0,0 +1,36 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"38f072be-5254-4cb7-b76e-d612b2ae3b3a"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784974101296,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1784974101297,"data":{"turn":1,"step":1,"index":0,"dt":[125,30,0,1,0,0,30,1,0,0,0,0,30,1,0,0,30,0,0,0,0,1,30,1,0,0],"texts":["The"," user"," wants"," me"," to"," read"," a",".txt"," and"," b",".txt",","," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," do"," both"," reads"," in"," parallel","."]}} +{"type":"assistant/chunk","seq":33,"time":1784974101666,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":34,"time0":1784974101667,"data":{"turn":1,"step":1,"index":1,"dt":[30,0,0,0,0,0,29,1,0,29,1],"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","args":["","{","\"","file","_path","\"",": ","\"","a",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":46,"time":1784974101821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":47,"time0":1784974101822,"data":{"turn":1,"step":1,"index":2,"dt":[27,0,0,0,1,31,0,1,0,26,1],"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","args":["","{","\"","file","_path","\"",": ","\"","b",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":59,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."}}}} +{"type":"assistant/chunk","seq":60,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}} +{"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}} +{"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"text","text":"I'll read both files."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"ec076738-f75d-4525-ba99-c8fc16acf955"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} +{"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}} +{"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}} +{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OsndvlcKnCcUmae7QXal8633"},"content":[{"type":"tool-result","toolCallId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ea2294eb-8652-4492-8a08-9c24d3f8a60f"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725"},"content":[{"type":"tool-result","toolCallId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"f02b3acf-4e03-4b4d-beeb-1a564c9c6d61"}},"sourceEventSeqs":[66],"surfaceOp":"append"} +{"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":72,"time0":1784974102397,"data":{"turn":1,"step":2,"index":0,"dt":[108,29,1,0,0,30,30,1,0,0,0,29,0,0,0,0,1,30,0,0,0,33,1,0,26,1,31,1],"texts":["Both"," files"," have"," been"," read","."," a",".txt"," contains"," \"","alpha","\""," and"," b",".txt"," contains"," \"","beta","\"."," I","'ll"," now"," reply"," with"," D","ONE"," as"," instructed","."]}} +{"type":"assistant/chunk","seq":101,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":102,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":103,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":104,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."}}}} +{"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"07627a5e-4cb2-47ef-9b50-88893aac7406"}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"} +{"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..b955730c7e 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -16,6 +16,7 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- paragraph: I'll read both files. - img - text: Read - button "a.txt" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 807205c03b..3564e426ab 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 3c15fe73c242ee63b9bbc93b602653b0f5f14af6 -README.zh.md: 61718fe8bdd73054057f7ce0ad6ba7ae8ce308a1 +README.md: 1dbaaf7cabcd172ab37aef82ba9ca6a2c7a70d01 +README.zh.md: f21a5819057af2f899dbdf039df35544627153d0 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3c15fe73c2..1dbaaf7cab 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ None; this package neither assembles nor sends a provider request. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free; branch remains a chrome stub. +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each settled turn only; mid-turn narration, an active turn's content, and Think-only nodes stay chrome-free; branch remains a chrome stub. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 61718fe8bd..f21a581905 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个 turn 最后一条带 text 的 assistant 消息下;turn 中间叙述与纯 Think 节点不带 chrome;分支仍是 chrome stub。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个已结束 turn 最后一条带 text 的 assistant 消息下;turn 中间叙述、活跃 turn 的内容与纯 Think 节点不带 chrome;分支仍是 chrome stub。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 16fa25f969..59719624c7 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,9 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' +import { + assistantActionsSeqs, deriveChatFlow, withholdActionsTurn, type ChatFlowItem, +} from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -236,6 +238,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) + // Primitive turn (or null): stable across chunk storms so this parent does + // not re-render per token the way a partial.blocks subscribe would. + const withholdTurn = useSession(s => withholdActionsTurn(s.running, s.partial, s.runningCalls)) const codeDispatches = useSession(s => s.codeDispatches) const openState = useSession(s => s.openState) const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) @@ -244,9 +249,12 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const selectedCallId = useStore(s => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) - // Only the last content assistant of each turn owns IconActions; mid-turn - // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. - const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) + // Settled turn-tail content only; a running turn withholds its whole seat so + // mid-turn narration does not flash copy/branch/clock while tools run. + const actionSeqs = useMemo( + () => assistantActionsSeqs(nodes, withholdTurn), + [nodes, withholdTurn], + ) const listRef = useRef(null) const atBottomRef = useRef(true) diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index ad98d3aaad..7e75a99be3 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -31,16 +31,43 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } +/** + * Turn whose content assistants must stay chrome-free while the turn is still + * running. Prefers the streaming partial, else the first in-flight tool call; + * returns null when `running` is false or neither signal exists yet (a brand-new + * turn before the first step must not strip a prior settled answer's seat). + * @param running - snapshot `running` bit. + * @param partial - in-flight assistant partial, or null. + * @param runningCalls - in-flight tool rows (same turn while tools execute). + * @returns Turn to withhold, or null. + */ +export function withholdActionsTurn( + running: boolean, + partial: { turn: number } | null, + runningCalls: readonly { turn: number }[], +): number | null { + if (!running) return null + if (partial !== null) return partial.turn + return runningCalls[0]?.turn ?? null +} + /** * Seq set of assistants that own IconActions: the last content-text assistant - * in each turn. Mid-turn narration (text before tools) stays chrome-free. + * in each *settled* turn. Mid-turn narration and every content assistant of a + * still-running turn stay chrome-free (no flash while tools run or the next + * step streams). * @param nodes - snapshot nodes (surface order). + * @param withholdTurn - active turn from {@link withholdActionsTurn}, or null. * @returns Seq values ChatView may pass as `time` into AssistantMarkdown. */ -export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet { +export function assistantActionsSeqs( + nodes: readonly ConversationNode[], + withholdTurn: number | null = null, +): ReadonlySet { const lastByTurn = new Map() for (const node of nodes) { if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue + if (withholdTurn !== null && node.turn === withholdTurn) continue lastByTurn.set(node.turn, node.seq) } return new Set(lastByTurn.values()) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 453e6a8f0f..4cd835694f 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -16,7 +16,9 @@ import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' -import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' +import { + assistantActionsSeqs, deriveChatFlow, flowKeys, withholdActionsTurn, +} from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -155,12 +157,12 @@ describe('chat-flow derivation', () => { expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') }) - it('assistantActionsSeqs keeps only the last content assistant per turn', () => { + it('assistantActionsSeqs keeps only the last content assistant per settled turn', () => { const thinkOnly: AssistantMessageNode = { kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2, blocks: [{ kind: 'reasoning', text: 'planning' }], } - const seqs = assistantActionsSeqs([ + const nodes: ConversationNode[] = [ user(1, 'hi'), assistant(2, 'looking', 1), thinkOnly, @@ -168,8 +170,18 @@ describe('chat-flow derivation', () => { assistant(5, 'done', 1), user(6, 'again'), assistant(7, 'second turn', 2), - ]) - expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) + ] + expect([...assistantActionsSeqs(nodes)].sort((a, b) => a - b)).toEqual([5, 7]) + // While turn 1 is still running, its tip content must not own the seat. + expect([...assistantActionsSeqs(nodes, 1)].sort((a, b) => a - b)).toEqual([7]) + }) + + it('withholdActionsTurn follows partial, then runningCalls, and ignores a bare running bit', () => { + expect(withholdActionsTurn(false, { turn: 2 }, [{ turn: 2 }])).toBeNull() + expect(withholdActionsTurn(true, { turn: 3 }, [{ turn: 2 }])).toBe(3) + expect(withholdActionsTurn(true, null, [{ turn: 2 }])).toBe(2) + // Turn accepted but no step output yet: do not strip a prior settled seat. + expect(withholdActionsTurn(true, null, [])).toBeNull() }) }) @@ -211,7 +223,7 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) - it('shows assistant IconActions only on the last content message of each turn', () => { + it('shows assistant IconActions only on the last content message of each settled turn', () => { const h = makeHarness({ nodes: [ user(1, 'hi'), @@ -228,6 +240,24 @@ describe('ChatView', () => { expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4) }) + it('withholds IconActions for a running turn while tools are in flight', () => { + const h = makeHarness({ + running: true, + runningCalls: [{ ...runningCall('a'), turn: 2 }], + nodes: [ + user(1, 'first'), + assistant(2, 'previous answer', 1), + user(3, 'second'), + assistant(4, 'mid-turn text', 2), + ], + }) + const view = render() + // 2 user + 1 settled turn-tail; running turn's mid-turn text stays chrome-free. + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3) + expect(view.getByText('mid-turn text')).toBeTruthy() + expect(view.getByText('previous answer')).toBeTruthy() + }) + it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { const markdown = '# Rendered\n\n- **one**\n- `two`' const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) From af015527891d6732147b07274fab6e0990b1b4a1 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 30 Jul 2026 19:57:24 +0800 Subject: [PATCH 03/22] fix(web): keep IconActions on turn-tail content only Drop the running-turn withhold path; last-content-seq gating alone keeps mid-turn narration chrome-free. --- ...b-message-icon-actions-and-clock.i18n.yaml | 4 +- ...7-29-web-message-icon-actions-and-clock.md | 10 ++--- ...9-web-message-icon-actions-and-clock.zh.md | 10 ++--- apps/web/tests/message-actions.e2e.ts | 28 ++++++------- .../snapshots/message-actions/ui.expected.md | 1 - .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/chat/ChatView.tsx | 16 ++----- .../src/client/chat/chat-flow.ts | 31 +------------- .../ui-conversation/tests/chat-view.spec.tsx | 42 +++---------------- 11 files changed, 42 insertions(+), 112 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index 0fcd9ae778..9e9755c0b5 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: b408a3c6d6bf3e0c11764c73c0ea7a859ce0adec -2026-07-29-web-message-icon-actions-and-clock.zh.md: 4440d6cacd3d2f75d758fa00315a653507c16928 +2026-07-29-web-message-icon-actions-and-clock.md: f3a5a0caf75627f5f6b37f0e9b9e5e4b5c6ac6c3 +2026-07-29-web-message-icon-actions-and-clock.zh.md: 9c51ec931a2e45a2d19122b7dddd31d1bb9d1299 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md index b408a3c6d6..f3a5a0caf7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -10,9 +10,9 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo ## Decision -**User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each *settled* turn appends a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** +**User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each turn appends a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** -Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `ChatView` derives turn-tail seqs via `assistantActionsSeqs` and withholds a still-running turn through `withholdActionsTurn` (streaming `partial.turn`, else the first `runningCalls` turn; a bare `running` bit before the first step does not strip a prior settled seat). Selectors return a primitive turn so chunk storms do not re-render the list parent. `AssistantMarkdown` places the row after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, an active turn's content, and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (owned cold seed with mid-turn narration text + aria golden that places copy only under the user bubble and the turn-tail `DONE`); aria normalization collapses every clock shape to `{{clock}}`. +Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `ChatView` derives turn-tail seqs via `assistantActionsSeqs` and withholds `time` for mid-turn content; `AssistantMarkdown` places the row after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. ## Alternatives considered @@ -20,9 +20,7 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day **Put IconActions under every finalized assistant node (including Think-only).** Rejected: copy has nothing useful to write without text content, and repeating the chrome under every step/Think row clutters the flow; only content output owns the seat. -**Put IconActions under every content-text assistant in a multi-step turn.** Rejected: mid-turn narration (text before tools) is not the settled answer; repeating copy/branch/clock under each step clutters the flow. Only the last content assistant of a settled turn owns the seat. - -**Derive the running-turn withhold from `running` plus the max turn among finalized nodes alone.** Rejected: after step-1 text lands and tools run for seconds that tip is temporarily "last content," so chrome would flash on then off; `partial` / `runningCalls` name the open turn without that flicker, and a bare `running` before the first step must leave the prior settled answer's seat alone. +**Put IconActions under every content-text assistant in a multi-step turn.** Rejected: mid-turn narration (text before tools) is not the settled answer; repeating copy/branch/clock under each step clutters the flow. Only the last content assistant of the turn owns the seat. **Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate. @@ -32,4 +30,4 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day ## Consequences -Each settled turn's last content answer exposes copy and the event clock as soon as the row mounts; mid-turn content, an active turn's content, and Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, the content-only gate, the turn-tail seq gate, and the running-turn withhold; the web e2e scenario pins the assembled IconActions chrome including mid-turn narration without a third copy control. +Each turn's last settled content answer exposes copy and the event clock as soon as the row mounts; mid-turn content and Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, the content-only gate, and the turn-tail seq gate; the web e2e scenario pins the assembled IconActions chrome. diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 4440d6cacd..9c51ec931a 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -10,9 +10,9 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 ## 决策 -**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;每个*已结束* turn 最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** +**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;每个 turn 最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** -两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导 turn 尾部 seq,并用 `withholdActionsTurn` 扣留仍在运行的 turn(优先流式 `partial.turn`,否则第一条 `runningCalls` 的 turn;在第一步出现前仅有 `running` 时不得撤掉上一回合已定稿答案的座位)。选择器返回原始 turn 值,因此 token 风暴不会让列表父级重渲染。`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、turn 中间叙述、活跃 turn 的内容与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(自有冷 seed,含 turn 中间叙述文本 + aria golden,仅在用户气泡与 turn 尾部 `DONE` 下放置复制)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 +两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导 turn 尾部 seq,并对 turn 中间内容不传 `time`;`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、turn 中间叙述与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 ## 曾考虑的方案 @@ -20,9 +20,7 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 **给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。 -**给多步 turn 中每一条带 text 的 assistant 都挂 IconActions。** 否决:turn 中间叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有已结束 turn 的最后一条内容 assistant 拥有该座位。 - -**仅用 `running` 加上已定稿节点中的最大 turn 推导运行中 turn 的扣留。** 否决:step-1 文本落地后工具可能跑数秒,该 tip 暂时就是「最后一条 content」,chrome 会先出现再消失;`partial`/`runningCalls` 能指名开放 turn 且无此闪烁,而第一步前仅有 `running` 时必须保留上一回合已定稿答案的座位。 +**给多步 turn 中每一条带 text 的 assistant 都挂 IconActions。** 否决:turn 中间叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有该 turn 最后一条内容 assistant 拥有该座位。 **在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。 @@ -32,4 +30,4 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 ## 后果 -每个已结束 turn 的最后一条内容回答在行挂载后立刻暴露复制与事件时钟;turn 中间内容、活跃 turn 的内容与纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、仅内容门控、turn 尾部 seq 门控与运行中 turn 扣留;Web e2e 场景钉住组装后的 IconActions chrome,含 turn 中间叙述且无第三个复制控件。 +每个 turn 最后一条已定稿内容回答在行挂载后立刻暴露复制与事件时钟;turn 中间内容与纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、仅内容门控与 turn 尾部 seq 门控;Web e2e 场景钉住组装后的 IconActions chrome。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index e9d1447e93..2f5f8f7abf 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -1,8 +1,7 @@ -// Web e2e scenario: message IconActions + clocks. Cold-seeds a closed turn that -// includes mid-turn narration text (so the turn-tail chrome gate is exercised) -// and pins the settled conversation aria after the user/assistant footers are -// focus-revealed — the surface package jsdom tests cannot substitute for -// (docs/testing.md snapshot rule). +// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history +// fixture (zero model calls) and pins the settled conversation aria after the +// user/assistant footers are focus-revealed — the surface package jsdom tests +// cannot substitute for (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,7 +15,9 @@ import { import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url)) -const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') +// Borrowed read-only: this scenario needs any settled user+assistant pair, not +// a new recording (workspace-management / sidebar-scrollbar pattern). +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' @@ -36,7 +37,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'seed must carry the drive prompt').toEqual([PROMPT]) + expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -50,7 +51,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('lists the seeded session and reveals turn-tail IconActions only', async () => { + it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -59,16 +60,15 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText("I'll read both files.").count(), { timeout: 10_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). Exactly one user row + one settled turn-tail - // assistant: mid-turn narration must not add a third copy control. + // hover/focus-within). User has three actions; each turn's last content + // assistant has copy + branch. const copyButtons = page.getByRole('button', { name: '复制' }) - await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) await copyButtons.first().focus() await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 }) - .toBe(2) + .toBeGreaterThanOrEqual(2) await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1) }, 60_000) @@ -88,6 +88,6 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index b955730c7e..19ba02d99d 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -16,7 +16,6 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- paragraph: I'll read both files. - img - text: Read - button "a.txt" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3564e426ab..a2fb733e02 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 1dbaaf7cabcd172ab37aef82ba9ca6a2c7a70d01 -README.zh.md: f21a5819057af2f899dbdf039df35544627153d0 +README.md: 77f73408953003899b0b90661b01a2d0411f01cb +README.zh.md: 1ce9787df01719b81115448f914a74aad9a99b16 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 1dbaaf7cab..77f7340895 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ A tool call declaring the `terminal` render intent renders its command output in Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. @@ -38,7 +38,7 @@ None; this package neither assembles nor sends a provider request. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each settled turn only; mid-turn narration, an active turn's content, and Think-only nodes stay chrome-free; branch remains a chrome stub. +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free; branch remains a chrome stub. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f21a581905..1ce9787df0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,7 +18,7 @@ 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 @@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个已结束 turn 最后一条带 text 的 assistant 消息下;turn 中间叙述、活跃 turn 的内容与纯 Think 节点不带 chrome;分支仍是 chrome stub。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个 turn 最后一条带 text 的 assistant 消息下;turn 中间叙述与纯 Think 节点不带 chrome;分支仍是 chrome stub。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 59719624c7..16fa25f969 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,9 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { - assistantActionsSeqs, deriveChatFlow, withholdActionsTurn, type ChatFlowItem, -} from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -238,9 +236,6 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) - // Primitive turn (or null): stable across chunk storms so this parent does - // not re-render per token the way a partial.blocks subscribe would. - const withholdTurn = useSession(s => withholdActionsTurn(s.running, s.partial, s.runningCalls)) const codeDispatches = useSession(s => s.codeDispatches) const openState = useSession(s => s.openState) const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) @@ -249,12 +244,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const selectedCallId = useStore(s => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) - // Settled turn-tail content only; a running turn withholds its whole seat so - // mid-turn narration does not flash copy/branch/clock while tools run. - const actionSeqs = useMemo( - () => assistantActionsSeqs(nodes, withholdTurn), - [nodes, withholdTurn], - ) + // Only the last content assistant of each turn owns IconActions; mid-turn + // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. + const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) const listRef = useRef(null) const atBottomRef = useRef(true) diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 7e75a99be3..ad98d3aaad 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -31,43 +31,16 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } -/** - * Turn whose content assistants must stay chrome-free while the turn is still - * running. Prefers the streaming partial, else the first in-flight tool call; - * returns null when `running` is false or neither signal exists yet (a brand-new - * turn before the first step must not strip a prior settled answer's seat). - * @param running - snapshot `running` bit. - * @param partial - in-flight assistant partial, or null. - * @param runningCalls - in-flight tool rows (same turn while tools execute). - * @returns Turn to withhold, or null. - */ -export function withholdActionsTurn( - running: boolean, - partial: { turn: number } | null, - runningCalls: readonly { turn: number }[], -): number | null { - if (!running) return null - if (partial !== null) return partial.turn - return runningCalls[0]?.turn ?? null -} - /** * Seq set of assistants that own IconActions: the last content-text assistant - * in each *settled* turn. Mid-turn narration and every content assistant of a - * still-running turn stay chrome-free (no flash while tools run or the next - * step streams). + * in each turn. Mid-turn narration (text before tools) stays chrome-free. * @param nodes - snapshot nodes (surface order). - * @param withholdTurn - active turn from {@link withholdActionsTurn}, or null. * @returns Seq values ChatView may pass as `time` into AssistantMarkdown. */ -export function assistantActionsSeqs( - nodes: readonly ConversationNode[], - withholdTurn: number | null = null, -): ReadonlySet { +export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet { const lastByTurn = new Map() for (const node of nodes) { if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue - if (withholdTurn !== null && node.turn === withholdTurn) continue lastByTurn.set(node.turn, node.seq) } return new Set(lastByTurn.values()) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 4cd835694f..453e6a8f0f 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -16,9 +16,7 @@ import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' -import { - assistantActionsSeqs, deriveChatFlow, flowKeys, withholdActionsTurn, -} from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -157,12 +155,12 @@ describe('chat-flow derivation', () => { expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') }) - it('assistantActionsSeqs keeps only the last content assistant per settled turn', () => { + it('assistantActionsSeqs keeps only the last content assistant per turn', () => { const thinkOnly: AssistantMessageNode = { kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2, blocks: [{ kind: 'reasoning', text: 'planning' }], } - const nodes: ConversationNode[] = [ + const seqs = assistantActionsSeqs([ user(1, 'hi'), assistant(2, 'looking', 1), thinkOnly, @@ -170,18 +168,8 @@ describe('chat-flow derivation', () => { assistant(5, 'done', 1), user(6, 'again'), assistant(7, 'second turn', 2), - ] - expect([...assistantActionsSeqs(nodes)].sort((a, b) => a - b)).toEqual([5, 7]) - // While turn 1 is still running, its tip content must not own the seat. - expect([...assistantActionsSeqs(nodes, 1)].sort((a, b) => a - b)).toEqual([7]) - }) - - it('withholdActionsTurn follows partial, then runningCalls, and ignores a bare running bit', () => { - expect(withholdActionsTurn(false, { turn: 2 }, [{ turn: 2 }])).toBeNull() - expect(withholdActionsTurn(true, { turn: 3 }, [{ turn: 2 }])).toBe(3) - expect(withholdActionsTurn(true, null, [{ turn: 2 }])).toBe(2) - // Turn accepted but no step output yet: do not strip a prior settled seat. - expect(withholdActionsTurn(true, null, [])).toBeNull() + ]) + expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) }) @@ -223,7 +211,7 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) - it('shows assistant IconActions only on the last content message of each settled turn', () => { + it('shows assistant IconActions only on the last content message of each turn', () => { const h = makeHarness({ nodes: [ user(1, 'hi'), @@ -240,24 +228,6 @@ describe('ChatView', () => { expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4) }) - it('withholds IconActions for a running turn while tools are in flight', () => { - const h = makeHarness({ - running: true, - runningCalls: [{ ...runningCall('a'), turn: 2 }], - nodes: [ - user(1, 'first'), - assistant(2, 'previous answer', 1), - user(3, 'second'), - assistant(4, 'mid-turn text', 2), - ], - }) - const view = render() - // 2 user + 1 settled turn-tail; running turn's mid-turn text stays chrome-free. - expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3) - expect(view.getByText('mid-turn text')).toBeTruthy() - expect(view.getByText('previous answer')).toBeTruthy() - }) - it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { const markdown = '# Rendered\n\n- **one**\n- `two`' const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) From 724c5e877e7a98c06608c1dd436c0960f7f9ce7c Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 30 Jul 2026 20:02:08 +0800 Subject: [PATCH 04/22] fix: cr --- .../typert/generator/tests/cordis-catalog-contract.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typert/generator/tests/cordis-catalog-contract.spec.ts b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts index 4092ac7e63..1bfe0f5e46 100644 --- a/packages/typert/generator/tests/cordis-catalog-contract.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts @@ -125,7 +125,7 @@ afterEach(() => { while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) }) -describe('gen-cordis-catalog collectEvents', () => { +describe('gen-cordis-catalog collectEvents', { timeout: 30_000 }, () => { it('extracts a well-formed event with its @mode and JSDoc', () => { const events = collectEvents(make( ' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', @@ -239,7 +239,7 @@ describe('gen-cordis-catalog collectEvents', () => { }) }) -describe('gen-cordis-catalog collectServices', () => { +describe('gen-cordis-catalog collectServices', { timeout: 30_000 }, () => { const WELL_FORMED = `/** Fixture service. */ export class FixService { /** From d704a2b7829b9ed549ef17c9ed5bfd648f0ebf0b Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 30 Jul 2026 20:19:42 +0800 Subject: [PATCH 05/22] fix: ci --- .../snapshots/message-actions/seed.jsonl | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 apps/web/tests/snapshots/message-actions/seed.jsonl diff --git a/apps/web/tests/snapshots/message-actions/seed.jsonl b/apps/web/tests/snapshots/message-actions/seed.jsonl deleted file mode 100644 index d8d2f54463..0000000000 --- a/apps/web/tests/snapshots/message-actions/seed.jsonl +++ /dev/null @@ -1,36 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"} -{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"38f072be-5254-4cb7-b76e-d612b2ae3b3a"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1784974101296,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1784974101297,"data":{"turn":1,"step":1,"index":0,"dt":[125,30,0,1,0,0,30,1,0,0,0,0,30,1,0,0,30,0,0,0,0,1,30,1,0,0],"texts":["The"," user"," wants"," me"," to"," read"," a",".txt"," and"," b",".txt",","," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," do"," both"," reads"," in"," parallel","."]}} -{"type":"assistant/chunk","seq":33,"time":1784974101666,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":34,"time0":1784974101667,"data":{"turn":1,"step":1,"index":1,"dt":[30,0,0,0,0,0,29,1,0,29,1],"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","args":["","{","\"","file","_path","\"",": ","\"","a",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":46,"time":1784974101821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":47,"time0":1784974101822,"data":{"turn":1,"step":1,"index":2,"dt":[27,0,0,0,1,31,0,1,0,26,1],"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","args":["","{","\"","file","_path","\"",": ","\"","b",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":59,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."}}}} -{"type":"assistant/chunk","seq":60,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}} -{"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}} -{"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"text","text":"I'll read both files."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"ec076738-f75d-4525-ba99-c8fc16acf955"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} -{"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}} -{"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}} -{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OsndvlcKnCcUmae7QXal8633"},"content":[{"type":"tool-result","toolCallId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ea2294eb-8652-4492-8a08-9c24d3f8a60f"}},"sourceEventSeqs":[65],"surfaceOp":"append"} -{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725"},"content":[{"type":"tool-result","toolCallId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"f02b3acf-4e03-4b4d-beeb-1a564c9c6d61"}},"sourceEventSeqs":[66],"surfaceOp":"append"} -{"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":72,"time0":1784974102397,"data":{"turn":1,"step":2,"index":0,"dt":[108,29,1,0,0,30,30,1,0,0,0,29,0,0,0,0,1,30,0,0,0,33,1,0,26,1,31,1],"texts":["Both"," files"," have"," been"," read","."," a",".txt"," contains"," \"","alpha","\""," and"," b",".txt"," contains"," \"","beta","\"."," I","'ll"," now"," reply"," with"," D","ONE"," as"," instructed","."]}} -{"type":"assistant/chunk","seq":101,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":102,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":103,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":104,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."}}}} -{"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}} -{"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"07627a5e-4cb2-47ef-9b50-88893aac7406"}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"} -{"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}} From daf70f36605c13fa9b580743b9110f792e0e42bb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 21:04:00 +0800 Subject: [PATCH 06/22] feat(llm-deepseek): configure max token defaults --- ...adapter-owned-max-token-defaults.i18n.yaml | 6 ++ ...-07-30-adapter-owned-max-token-defaults.md | 33 ++++++++ ...-30-adapter-owned-max-token-defaults.zh.md | 33 ++++++++ ...2026-07-28-sdk-max-output-tokens.i18n.yaml | 4 +- .../2026-07-28-sdk-max-output-tokens.md | 4 +- .../2026-07-28-sdk-max-output-tokens.zh.md | 4 +- apps/cli/config/tui.cordis.yml | 4 +- docs/config-catalog.md | 6 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 6 +- docs/core-data-structures/core.zh.md | 6 +- .../llm-streaming.i18n.yaml | 4 +- docs/core-data-structures/llm-streaming.md | 4 +- docs/core-data-structures/llm-streaming.zh.md | 4 +- examples/acp-agent/cordis.yml | 3 +- examples/acp-agent/retry.cordis.yml | 1 - examples/headless-agent/cordis.yml | 4 +- .../fixtures/deepseek-defaults.cordis.yml | 17 ++++ .../headless-agent/tests/headless.snapshot.ts | 84 +++++++++++++++++++ examples/jsonrpc-agent/cordis.yml | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- .../core/agent-loop/tests/mock-adapter.ts | 2 + .../tests/request-reconstruction.spec.ts | 16 ++++ packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 9 +- packages/llm/llm-deepseek/README.zh.md | 9 +- packages/llm/llm-deepseek/src/adapter.ts | 23 +++-- packages/llm/llm-deepseek/src/index.ts | 30 +++++-- packages/llm/llm-deepseek/src/serialize.ts | 10 ++- .../llm/llm-deepseek/tests/adapter.spec.ts | 43 +++++++++- .../llm/llm-deepseek/tests/serialize.spec.ts | 7 ++ packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 12 +-- packages/llm/llm/README.zh.md | 12 +-- packages/llm/llm/src/index.ts | 27 ++++-- packages/llm/llm/src/types.ts | 2 + packages/llm/llm/tests/service.spec.ts | 42 ++++++++++ packages/sdk/sdk-protocol/README.i18n.yaml | 4 +- packages/sdk/sdk-protocol/README.md | 2 +- packages/sdk/sdk-protocol/README.zh.md | 2 +- .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 2 +- .../subagent/subagent-dsh-sdk/README.zh.md | 2 +- packages/ui/jsonrpc/README.i18n.yaml | 4 +- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/README.zh.md | 2 +- 50 files changed, 430 insertions(+), 95 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md create mode 100644 examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml diff --git a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml new file mode 100644 index 0000000000..0cd4e5f83e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md +2026-07-30-adapter-owned-max-token-defaults.md: c6fc9f014124607a3e2c3520f9f3b9023b77935f +2026-07-30-adapter-owned-max-token-defaults.zh.md: e0670f43eb5f27c2307c734e01a5b6718c09a67e diff --git a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md new file mode 100644 index 0000000000..c6fc9f0141 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md @@ -0,0 +1,33 @@ +# Agent Note: Adapter-owned max-token defaults + +Status: implemented + +English | [中文](2026-07-30-adapter-owned-max-token-defaults.zh.md) + +## Problem + +An LLM adapter could serialize an explicit `GenerateOptions.maxTokens`, but its Cordis configuration could not establish a reconstructable conversation default. Applying a fallback only inside provider serialization would make the wire request differ from the durable `request/header`; putting every provider's default in Agent Loop would instead transfer deployment and model policy into the provider-neutral driver. + +## Decision + +`LlmResolvedModelInfo.defaultMaxTokens` carries an optional adapter-configured per-request output cap for one exact provider/model route. `LlmService` validates it as a positive safe integer and materializes it into `LlmCallConfig.maxTokens` only when the caller omitted a value. Explicit request or Agent options therefore win without clamping. + +The agent loop continues to prepare calls before logging `request/header`, so an adapter default becomes a durable request fact before dispatch. Direct `LlmService.stream()` calls resolve the same default at the final adapter boundary. The field is a request default rather than a hard model output limit; adapters that preserve provider-owned defaults omit it. + +The native DeepSeek adapter exposes `maxTokens` in Cordis config with a 256,000-token default and maps the effective value to `max_tokens`. Its default context capacity is 1,000,000 tokens: both built-in V4 entries publish that exact capacity, while configured entries without capacity and unlisted pass-through ids inherit the same adapter-wide fallback. + +## Alternatives considered + +**Apply the default only in DeepSeek serialization.** Rejected because the provider wire would contain a model-visible value absent from the durable request header. + +**Set `AgentOptions.maxTokens` in every shipped application.** Rejected because applications would duplicate adapter deployment policy, direct LLM calls would behave differently, and selecting another provider would retain a DeepSeek-specific cap. + +**Represent 256,000 as a hard per-model maximum.** Rejected because the configured value is the desired request budget, not evidence that every configured endpoint rejects larger outputs. Explicit callers remain authoritative. + +**Leave the provider default in control.** Rejected for the native DeepSeek deployment because the product requires a stable 256,000-token conversation budget across compatible endpoints. + +## Consequences + +DeepSeek conversations send `max_tokens: 256000` by default, and the same value appears in the session request header. Deployments can change the adapter default through `llm-deepseek.config.maxTokens`; per-agent and per-request values override it. Other adapters retain their existing behavior until they intentionally publish `defaultMaxTokens`. + +The 256,000-token output budget reserves a large part of the one-million-token context on endpoints that pre-allocate requested output. Deployments whose gateway or model supports a smaller budget must lower `maxTokens`; the explicit configuration is preferable to an undocumented provider fallback. diff --git a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md new file mode 100644 index 0000000000..e0670f43eb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 适配器持有的最大 token 默认值 + +Status: implemented + +[English](2026-07-30-adapter-owned-max-token-defaults.md) | 中文 + +## Problem + +LLM(大语言模型)适配器可以序列化显式的 `GenerateOptions.maxTokens`,但无法通过 Cordis 配置建立可重建的对话默认值。仅在提供方序列化中应用回退,会导致协议请求与持久 `request/header` 不一致;若将各提供方默认值都放进 agent loop(智能体循环),则会把部署与模型策略转移到提供方无关的驱动器中。 + +## Decision + +`LlmResolvedModelInfo.defaultMaxTokens` 携带一条确切提供方/模型路由的可选单次请求输出上限,该值由适配器配置。`LlmService` 将其校验为正安全整数,并且仅在调用方省略值时才填入 `LlmCallConfig.maxTokens`。因此,显式请求值或 Agent 选项优先,且不会被自动调整。 + +agent loop 仍在记录 `request/header` 前准备调用,因此适配器默认值会在分派前成为持久请求事实。直接调用 `LlmService.stream()` 时,也会在最终适配器边界解析同一默认值。该字段是请求默认值,而非模型输出硬上限;保留提供方持有默认值的适配器会省略它。 + +原生 DeepSeek 适配器在 Cordis 配置中公开 `maxTokens`,默认值为 256,000 token,并将生效值映射为 `max_tokens`。其默认上下文容量为 1,000,000 token:两个内置 V4 配置项均公布这一精确容量;不含容量的已配置项和未列出的原样传递 id 则继承同一个适配器级回退值。 + +## Alternatives considered + +**仅在 DeepSeek 序列化中应用默认值。** 不予采纳,因为提供方协议会包含持久请求 header 中缺失的模型可见值。 + +**在每个已发布应用中设置 `AgentOptions.maxTokens`。** 不予采纳,因为应用会重复适配器部署策略,直接 LLM 调用的行为将不同,而且选择另一个提供方后仍会保留 DeepSeek 专用上限。 + +**将 256,000 表示为每模型硬上限。** 不予采纳,因为配置值是所需请求预算,无法证明每个已配置端点都会拒绝更大的输出。显式调用方仍具有最终决定权。 + +**由提供方默认值控制。** 对原生 DeepSeek 部署不予采纳,因为产品要求各兼容端点都采用稳定的 256,000 token 对话预算。 + +## Consequences + +DeepSeek 对话默认发送 `max_tokens: 256000`,会话请求 header 中也会出现相同的值。部署可以通过 `llm-deepseek.config.maxTokens` 更改适配器默认值;每个 agent 和每次请求的值都会覆盖它。其他适配器会保留现有行为,直至主动公布 `defaultMaxTokens`。 + +对于预分配请求输出的端点,256,000 token 的输出预算会占用 1,000,000 token 上下文中的很大部分。如果部署使用的 gateway 或模型仅支持较小预算,则必须调低 `maxTokens`;显式配置优于未记录的提供方回退值。 diff --git a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.i18n.yaml index bdec24c41a..887b18118d 100644 --- a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.md -2026-07-28-sdk-max-output-tokens.md: 5db48f21892d56addea7b72f73319f9dbfd1e71f -2026-07-28-sdk-max-output-tokens.zh.md: 38b172718716d100726188163ff22be9ea0a7325 +2026-07-28-sdk-max-output-tokens.md: 3ba3e226d64b7d3d192d67bd88af463d2b0d9dc5 +2026-07-28-sdk-max-output-tokens.zh.md: aec566011d2d7a311b4de509c47ebba383c3b0d7 diff --git a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.md b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.md index 5db48f2189..3ba3e226d6 100644 --- a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.md +++ b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.md @@ -12,7 +12,7 @@ The Python and TypeScript SDKs could select a provider and model but could not b The high-level SDKs expose one optional process-wide output cap: Python names it `max_tokens`, TypeScript names it `maxTokens`, and the shared `initialize` wire payload carries `maxTokens`. The JSON-RPC server rejects values that are not positive safe integers and stores the accepted cap with its provider/model route. -Each SDK-created root Agent receives the cap through `AgentOptions.maxTokens`. Agent Loop places that value in the initial `LlmCallConfig`, logs it in the request header, and reconstructs every dispatched conversation request from that durable header. Omitting the option leaves `maxTokens` absent so the selected provider retains its default. +Each SDK-created root Agent receives the cap through `AgentOptions.maxTokens`. Agent Loop places that value in the initial `LlmCallConfig`; final call preparation preserves the explicit value or materializes an exact-model adapter default, logs the effective cap in the request header, and reconstructs every dispatched conversation request from that durable header. Omitting the SDK option therefore allows the selected adapter or provider route default to apply. In-process subagents inherit the parent's provider, model, and output cap. An explicit `SubagentStartRequest.agentOptions.maxTokens`, including one configured by `dsh-tool-subagent`, overrides the inherited value for that child and its descendants. Out-of-process providers own the configuration of their separate runtime; `subagent-dsh-sdk` therefore exposes its own optional `maxTokens` and forwards it through that child runtime's SDK handshake. @@ -20,7 +20,7 @@ Compaction, session-title generation, web search, and other auxiliary calls keep ## Alternatives considered -**Set an adapter environment variable.** This would be DeepSeek-adapter-specific, invisible in the session request header, ineffective for intercepted or alternate adapters, and easy to confuse with a provider default. The cap belongs in provider-neutral request configuration. +**Set only an adapter environment variable.** A serializer-private fallback would be DeepSeek-adapter-specific, invisible in the session request header, ineffective for intercepted or alternate adapters, and easy to confuse with a provider default. Adapter-owned defaults may instead be exposed as exact-model metadata and materialized into provider-neutral request configuration before logging. **Add `maxTokens` to every `session/prompt`.** Per-turn mutation would enlarge the wire and introduce request-config transitions that callers do not need for the current evaluation use case. A runtime initialization option gives every session in one SDK process the same reproducible budget. diff --git a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.zh.md b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.zh.md index 38b1727187..aec566011d 100644 --- a/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.zh.md @@ -12,7 +12,7 @@ Python 与 TypeScript SDK 可以选择提供方和模型,却无法限制对话 高层 SDK 公开一个可选的进程级输出上限:Python 命名为 `max_tokens`,TypeScript 命名为 `maxTokens`,共享的 `initialize` 线载荷使用 `maxTokens`。JSON-RPC 服务端拒绝非正安全整数,并将通过校验的上限与提供方/模型路由一同保存。 -每个由 SDK 创建的根 Agent 都通过 `AgentOptions.maxTokens` 获得该上限。Agent Loop 将它放入初始 `LlmCallConfig`、记录到请求 header,并从该持久化 header 重建每次分派的对话请求。省略该选项时,`maxTokens` 保持缺失,由所选提供方保留默认值。 +每个由 SDK 创建的根 Agent 都通过 `AgentOptions.maxTokens` 获得该上限。Agent Loop 将它放入初始 `LlmCallConfig`;最终调用准备会保留显式值,或填入确切模型的适配器默认值,再将生效上限记录到请求 header,并从该持久化 header 重建每次分派的对话请求。因此,省略 SDK 选项时会应用所选适配器或提供方路由的默认值。 进程内 subagent 继承父级的提供方、模型和输出上限。显式的 `SubagentStartRequest.agentOptions.maxTokens`(包括通过 `dsh-tool-subagent` 配置的值)会覆盖该子级及其后代的继承值。进程外提供方自行持有其独立运行时的配置;因此 `subagent-dsh-sdk` 公开独立的可选 `maxTokens`,并通过该子运行时自己的 SDK 握手传入。 @@ -20,7 +20,7 @@ Python 与 TypeScript SDK 可以选择提供方和模型,却无法限制对话 ## Alternatives considered -**设置适配器环境变量。** 这种方式仅适用于 DeepSeek 适配器,不会出现在会话请求 header 中,对被拦截请求或其他适配器无效,也容易与提供方默认值混淆。该上限属于提供方无关的请求配置。 +**仅设置适配器环境变量。** 序列化器私有回退仅适用于 DeepSeek 适配器,不会出现在会话请求 header 中,对被拦截请求或其他适配器无效,也容易与提供方默认值混淆。适配器持有的默认值可以改为通过确切模型元数据公开,并在记录前填入提供方无关的请求配置。 **在每个 `session/prompt` 上增加 `maxTokens`。** 按轮次修改会扩大线协议,并引入当前评测用例不需要的请求配置转换。运行时初始化选项可让一个 SDK 进程中的每个会话拥有相同、可重现的预算。 diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index d6099a5736..b271a11f05 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -36,8 +36,8 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Shipped default: full thinking at max effort on every request (wire-only -# defaults; they never enter the request header). +# Shipped default: full thinking at max effort on every request. Exact-model +# resolution materializes the effort before the request header is logged. - id: llm-deepseek config: apiKey: !!js process.env.DEEPSEEK_API_KEY diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dde2d6f65b..168e763d72 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -617,7 +617,9 @@ export interface Config { thinking?: 'enabled' | 'disabled' /** Default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Positive context capacity used when the selected model has no exact value. */ + /** Default per-request output cap (default 256,000); explicit request values win. */ + maxTokens?: number + /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ defaultContextWindow?: number /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] @@ -642,7 +644,7 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:46`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ee713636ac..97500f2747 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -782,7 +782,7 @@ async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ) /** * Validate a conversation call config against its exact model capability and - * materialize an adapter-configured default. Unsupported explicit efforts + * materialize adapter-configured defaults. Unsupported explicit efforts * reject before provider I/O; no clamping or aliasing is performed. This * standalone query does not bind a later dispatch; use {@link prepareCall} * when logging and streaming must share one adapter registration. diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 6321c85127..74bac3a63c 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: dad533cee00646a40f57bd9097b2cceb8e9de9e2 -core.zh.md: 9e8afac0744fcf0df8c35dad5debce746d6614c6 +core.md: 782d51b5178276ca1cb313607165b17b8e5d6a02 +core.zh.md: d1c06447d9a0675d87eb263d7486b64cd4677373 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dad533cee0..782d51b517 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -205,7 +205,7 @@ interface LlmModelInfo { } ``` -Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution. +Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity, adapter call defaults, and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution. ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -252,6 +252,8 @@ interface LlmModelReasoningInfo { interface LlmResolvedModelInfo extends LlmModelInfo { /** Provider-owned context capacity when known. */ context?: LlmModelContext + /** Adapter-configured per-request output cap materialized when callers omit one. */ + defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo } @@ -604,7 +606,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission leaves the provider default in control. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. Only the loop reads the cause (`user`, `parent`, or lifecycle-only `disposed`) back off its own machine-private signal at settlement — there is no public reader, and a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 9e8afac074..d1c06447d9 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -211,7 +211,7 @@ interface LlmModelInfo { } ``` -对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。 +对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量、适配器调用默认值和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。 ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -258,6 +258,8 @@ interface LlmModelReasoningInfo { interface LlmResolvedModelInfo extends LlmModelInfo { /** Provider-owned context capacity when known. */ context?: LlmModelContext + /** Adapter-configured per-request output cap materialized when callers omit one. */ + defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo } @@ -612,7 +614,7 @@ interface Agent { } ``` -`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时由提供方默认值控制。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。只有 loop 会在结算时从自己机器私有的 signal 上读回 cause(`user`、`parent` 或仅用于生命周期的 `disposed`)——不存在公开的读取器,signal 也不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index c3924f184b..61594c9066 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/llm-streaming.md -llm-streaming.md: 6811611768a0ec577360a8ff82792899cf3b8fec -llm-streaming.zh.md: 35374af6a20086f15384840689dba5aa24351750 +llm-streaming.md: d9c1772e2fb56de2f240b4e62fdc2e1aa12ae787 +llm-streaming.zh.md: 824b7c46186f2e330ad2a3aafa7a046759ab9a89 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 6811611768..d9c1772e2f 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -162,7 +162,7 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -215,7 +215,7 @@ declare abstract class LlmAdapter { * @param model - exact model id passed to {@link GenerateOptions.model}. * @param _signal - cancellation for this exact-model lookup; asynchronous * implementations must settle promptly after it aborts. - * @returns provider/model identity plus any context and reasoning metadata. + * @returns provider/model identity plus any context, call-default, and reasoning metadata. */ resolveModel( provider: string, diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 35374af6a2..824b7c4618 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -162,7 +162,7 @@ declare class BlockAssembler { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -215,7 +215,7 @@ declare abstract class LlmAdapter { * @param model - exact model id passed to {@link GenerateOptions.model}. * @param _signal - cancellation for this exact-model lookup; asynchronous * implementations must settle promptly after it aborts. - * @returns provider/model identity plus any context and reasoning metadata. + * @returns provider/model identity plus any context, call-default, and reasoning metadata. */ resolveModel( provider: string, diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index f784972429..ad513c5da3 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -5,7 +5,7 @@ # carries ACP JSON-RPC. # The DeepSeek adapter. Shipped default: full thinking at max effort on every -# request (wire-only defaults; they never enter the request header). +# request; exact-model resolution materializes request defaults before logging. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: @@ -13,7 +13,6 @@ baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max - defaultContextWindow: 256000 models: - id: deepseek-v4-flash - id: deepseek-v4-pro diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml index 57e364a694..7bdb15bb1f 100644 --- a/examples/acp-agent/retry.cordis.yml +++ b/examples/acp-agent/retry.cordis.yml @@ -17,7 +17,6 @@ baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max - defaultContextWindow: 256000 retryPolicy: mode: normal maxRetries: 2 diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 896c73469b..038a2da7f6 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -4,8 +4,8 @@ # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed # twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). -# Shipped default: full thinking at max effort on every request (wire-only -# defaults; they never enter the request header). +# Shipped default: full thinking at max effort on every request. Exact-model +# resolution materializes the effort before the request header is logged. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml new file mode 100644 index 0000000000..ad738c45c8 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -0,0 +1,17 @@ +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../../cordis.yml + patches: + - id: llm-deepseek + config: + apiKey: snapshot-key + baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL + thinking: disabled + - id: cli-agent + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: './.sessions' + workspaceContext: false + persona: 'Keyless DeepSeek adapter defaults snapshot.' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 2581d8a042..511d41e6b2 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -1,4 +1,6 @@ import { readFile, readdir, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import type { IncomingMessage, ServerResponse } from 'node:http' import { delimiter, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -32,6 +34,7 @@ const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', im const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) +const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -43,6 +46,40 @@ interface PersistedLog { readonly header: JsonObject } +interface DeepSeekDefaultsServer { + readonly url: string + readonly requests: JsonObject[] + close(): Promise +} + +/** Serve one deterministic DeepSeek-compatible response while retaining its request body. */ +async function deepseekDefaultsServer(): Promise { + const requests: JsonObject[] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as JsonObject) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end([ + 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('DeepSeek defaults snapshot server has no port') + return { + url: `http://127.0.0.1:${address.port}`, + requests, + close: () => new Promise(resolve => server.close(() => { resolve() })), + } +} + function parseJsonl(content: string): JsonObject[] { return content.split('\n') .filter(line => line.trim().length > 0) @@ -208,6 +245,53 @@ describe('headless stream-json snapshots', () => { `) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs and sends the DeepSeek adapter maxTokens default through the one-shot app', async () => { + const server = await deepseekDefaultsServer() + try { + const result = await runLoaderSmoke({ + label: 'DeepSeek adapter defaults headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-deepseek-defaults-', + binScript, + configPath: deepseekDefaultsConfigPath, + binArgs: [ + '--config', + deepseekDefaultsConfigPath, + '--output-format', + 'stream-json', + 'return the deterministic response', + ], + tsconfigPath, + env: { + DSH_SNAPSHOT_BASE_URL: server.url, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + }) + + expect(result.stderr).toBe('') + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.max_tokens).toBe(256_000) + const config = parseJsonl(result.stdout) + .map(record => record.event) + .find((event): event is JsonObject => ( + event !== null + && typeof event === 'object' + && !Array.isArray(event) + && 'type' in event + && event.type === 'request/header' + ))?.data as JsonObject | undefined + expect((config?.header as JsonObject | undefined)?.config).toMatchInlineSnapshot(` + { + "maxTokens": 256000, + "model": "deepseek-v4-flash", + "provider": "deepseek", + "reasoningEffort": "off", + } + `) + } finally { + await server.close() + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('replays the advanced toolchain through the one-shot app', async () => { const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const fixtureFiles = [ diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index b23dd30b4a..9806413725 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -7,8 +7,8 @@ maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" # The DeepSeek adapter. Shipped default: full thinking at max effort on every -# request (wire-only defaults; they never enter the request header). The model -# arrives per session over JSON-RPC, so it is not pinned here. +# request; exact-model resolution materializes request defaults before logging. +# The model arrives per session over JSON-RPC, so it is not pinned here. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d7cdc4e253..c12905ba85 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -404,7 +404,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise', - jsDoc: '/**\n * Validate a conversation call config against its exact model capability and\n * materialize an adapter-configured default. Unsupported explicit efforts\n * reject before provider I/O; no clamping or aliasing is performed. This\n * standalone query does not bind a later dispatch; use {@link prepareCall}\n * when logging and streaming must share one adapter registration.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a detached config only when a default must be materialized.\n */', + jsDoc: '/**\n * Validate a conversation call config against its exact model capability and\n * materialize adapter-configured defaults. Unsupported explicit efforts\n * reject before provider I/O; no clamping or aliasing is performed. This\n * standalone query does not bind a later dispatch; use {@link prepareCall}\n * when logging and streaming must share one adapter registration.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a detached config only when a default must be materialized.\n */', }, { signature: 'async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise', @@ -1937,7 +1937,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmResolvedModelInfo', - declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n reasoning?: LlmModelReasoningInfo;\n}', + declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n}', }, { name: 'Message', diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index e754f1bd90..6e592d9311 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -67,6 +67,7 @@ export class MockAdapter extends LlmAdapter { constructor( private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[], private readonly reasoning?: LlmModelReasoningInfo, + private readonly defaultMaxTokens?: number, ) { super() } @@ -80,6 +81,7 @@ export class MockAdapter extends LlmAdapter { id: model, name: model, ...this.reasoning === undefined ? {} : { reasoning: this.reasoning }, + ...this.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: this.defaultMaxTokens }, }) } diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index d4150c0521..f27aeabe7b 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -158,6 +158,22 @@ describe('request stability across the loop', () => { } }) + it('logs an adapter-owned maxTokens default before dispatch', async () => { + const adapter = new MockAdapter([textResponse('bounded')], undefined, 256_000) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('adapter-max-tokens'), { + provider: 'mock', + model: 'mock', + }) + + send(agent, 'use the adapter output limit') + await waitForIdle(ctx, agent) + + expect(adapter.requests[0]?.maxTokens).toBe(256_000) + const header = agent.session.events.find(event => event.type === 'request/header') + expect(header?.type === 'request/header' && header.data.header.config.maxTokens).toBe(256_000) + }) + it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 78a933294a..c2f6e1516f 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 6bd5279ace93b6d2569833be6f102c854105c2eb -README.zh.md: cdbb0c0b70124e0037a0f7a7a03ddedf1e3b78d3 +README.md: 0b55381ee484eb0044b1ebb88cfd137a987a9b3e +README.zh.md: 9fb0100fb2c212627b8c14dad0aada0f73d11c10 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 6bd5279ace..0b55381ee4 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -14,7 +14,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. -`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop records the cap in the request header and applies it to each conversation-model request; callers that omit it leave provider defaults in control. +`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index cdbb0c0b70..9fb0100fb2 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -14,7 +14,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 -`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会把该上限记录到请求 header,并应用到每次对话模型请求;调用方省略时由提供方默认值控制。 +`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 - `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber dispose。 - 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 594600e8c3..73e131f0e4 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 7a2314ea2ca0fb6606310a4961fcbc658240a7b8 -README.zh.md: d73145f8b8a32d8a515b6b4c0e916f0a11cd4771 +README.md: 5a22689d0b15ae5de1b82e37cf8c2c283c14af97 +README.zh.md: 0fa8b16eee72eee741b07d27b1ed61297f5420c2 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7a2314ea2c..5a22689d0b 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -18,6 +18,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high + maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default retryPolicy: # optional; omission uses bounded normal defaults mode: always # normal | always @@ -25,7 +26,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value + defaultContextWindow: 1000000 # optional positive-integer fallback; this is the default models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek-V4-Flash @@ -34,9 +35,11 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire contextWindow: 64000 ``` -The plugin registers the single provider route `deepseek` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 256,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. -`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists, `context` is absent without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. +`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. + +`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index d73145f8b8..0fa8b16eee 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -18,6 +18,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high + maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default retryPolicy: # optional; omission uses bounded normal defaults mode: always # normal | always @@ -25,7 +26,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value + defaultContextWindow: 1000000 # optional positive-integer fallback; this is the default models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek-V4-Flash @@ -34,9 +35,11 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: contextWindow: 64000 ``` -该插件注册唯一提供方路由 `deepseek`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 256,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 +该插件注册唯一提供方路由 `deepseek`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 -`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时,`context` 字段缺失但不会使路由失效。因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 +`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 + +`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。 同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index ff5ce9bf72..f2a083d55b 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -42,6 +42,8 @@ export interface DeepSeekAdapterOptions { baseURL: string /** Request defaults applied to every call (thinking mode, effort). */ defaults?: RequestDefaults + /** Default per-request output cap; explicit request values win. */ + maxTokens?: number /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number /** Advisory models exposed to discovery consumers; requests remain unrestricted. */ @@ -54,6 +56,10 @@ export interface DeepSeekAdapterOptions { /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 +/** Default combined request/response context capacity. */ +export const DEFAULT_CONTEXT_WINDOW = 1_000_000 +/** Default per-request output-token cap. */ +export const DEFAULT_MAX_TOKENS = 256_000 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' const OFF_REASONING_EFFORT = ReasoningEffortId('off') const HIGH_REASONING_EFFORT = ReasoningEffortId('high') @@ -120,6 +126,8 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin export class DeepSeekAdapter extends LlmAdapter { private readonly streamIdleTimeoutMs: number private readonly retryPolicy: ResolvedRetryPolicy + private readonly defaultContextWindow: number + private readonly maxTokens: number constructor(private readonly options: DeepSeekAdapterOptions) { super() @@ -128,10 +136,14 @@ export class DeepSeekAdapter extends LlmAdapter { && options.defaults.reasoningEffort !== 'off') { throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') } - if (options.defaultContextWindow !== undefined - && (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) { + this.defaultContextWindow = options.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW + if (!Number.isInteger(this.defaultContextWindow) || this.defaultContextWindow <= 0) { throw new Error('llm-deepseek: defaultContextWindow must be a positive integer') } + this.maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS + if (!Number.isSafeInteger(this.maxTokens) || this.maxTokens <= 0) { + throw new Error('llm-deepseek: maxTokens must be a positive safe integer') + } this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS if (!Number.isFinite(this.streamIdleTimeoutMs) || this.streamIdleTimeoutMs <= 0 @@ -162,12 +174,13 @@ export class DeepSeekAdapter extends LlmAdapter { ): Promise { const configured = this.options.models?.find(entry => entry.id === model) const contextWindow = configured?.contextWindow - ?? this.options.defaultContextWindow + ?? this.defaultContextWindow return Promise.resolve({ ...configured === undefined ? { provider, id: model, name: model } : modelInfo(provider, configured), - ...contextWindow === undefined ? {} : { context: { contextWindow } }, + context: { contextWindow }, + defaultMaxTokens: this.maxTokens, ...this.options.defaults?.thinking === 'disabled' ? { reasoning: { @@ -231,7 +244,7 @@ export class DeepSeekAdapter extends LlmAdapter { } private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable { - const body = serializeRequest(options, this.options.defaults ?? {}) + const body = serializeRequest(options, this.options.defaults, this.maxTokens) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 00db46d642..53186b8938 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -10,10 +10,20 @@ import z from 'schemastery' import { RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' +import { + DEFAULT_CONTEXT_WINDOW, + DEFAULT_MAX_TOKENS, + DEFAULT_STREAM_IDLE_TIMEOUT_MS, + DeepSeekAdapter, +} from './adapter.ts' import type { DeepSeekCatalogModel } from './adapter.ts' -export { DeepSeekAdapter } from './adapter.ts' +export { + DEFAULT_CONTEXT_WINDOW, + DEFAULT_MAX_TOKENS, + DEFAULT_STREAM_IDLE_TIMEOUT_MS, + DeepSeekAdapter, +} from './adapter.ts' export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts' export type { RequestDefaults } from './serialize.ts' export type * from './types.ts' @@ -22,8 +32,8 @@ export const name = 'llm-deepseek' export const inject = ['llm'] const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ - { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 256_000 }, + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW }, ] /** @@ -42,7 +52,9 @@ export interface Config { thinking?: 'enabled' | 'disabled' /** Default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Positive context capacity used when the selected model has no exact value. */ + /** Default per-request output cap (default 256,000); explicit request values win. */ + maxTokens?: number + /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ defaultContextWindow?: number /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] @@ -64,7 +76,8 @@ export const Config: z = z.object({ baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), reasoningEffort: z.union(['off', 'high', 'max']), - defaultContextWindow: z.number().step(1).min(1), + maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS), + defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), retryPolicy: RetryPolicySchema, @@ -116,9 +129,8 @@ export function apply(ctx: Context, config: Config): void { thinking: config.thinking, reasoningEffort: config.reasoningEffort, }, - ...config.defaultContextWindow === undefined - ? {} - : { defaultContextWindow: config.defaultContextWindow }, + maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS, + defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, models: resolveModels(config.models), streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, ...config.retryPolicy === undefined ? {} : { retryPolicy: config.retryPolicy }, diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index fb6b8d9117..d0f0081fae 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -137,9 +137,14 @@ export function serializeMessages(messages: Message[]): WireMessage[] { * provider defaults apply. * @param options - the harness request (model, history, system, tools, sampling). * @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire. + * @param defaultMaxTokens - adapter output default used only when the request omits a cap. * @returns the chat-completions request body. */ -export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest { +export function serializeRequest( + options: GenerateOptions, + defaults: RequestDefaults = {}, + defaultMaxTokens?: number, +): WireRequest { const messages: WireMessage[] = [] if (options.system !== undefined) { messages.push({ role: 'system', content: options.system }) @@ -157,6 +162,7 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa // A short title budget must produce visible text; conversation and // compaction calls continue to inherit the adapter's thinking defaults. const resolvedThinking = resolveThinking(options, defaults) + const maxTokens = options.maxTokens ?? defaultMaxTokens return { model: options.model, @@ -169,7 +175,7 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa : {}, ...tools !== undefined && tools.length > 0 ? { tools } : {}, ...options.temperature !== undefined ? { temperature: options.temperature } : {}, - ...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}, + ...maxTokens === undefined ? {} : { max_tokens: maxTokens }, ...options.stop !== undefined ? { stop: options.stop } : {}, } } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 1b64c57982..22cc5fd068 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -124,6 +124,7 @@ describe('DeepSeekAdapter against a mock server', () => { // The wire request carried the auth header contents we configured. expect(server.requests[0]).toMatchObject({ model: 'deepseek-v4-flash', + max_tokens: 256_000, reasoning_effort: 'high', stream: true, stream_options: { include_usage: true }, @@ -232,6 +233,20 @@ describe('DeepSeekAdapter against a mock server', () => { }) }) + it('uses the configured maxTokens default and preserves an explicit request cap', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const ctx = await harness(server.url, { maxTokens: 32_000 }) + + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], maxTokens: 8_192 }) + + expect(server.requests[0]).toMatchObject({ max_tokens: 32_000 }) + expect(server.requests[1]).toMatchObject({ max_tokens: 8_192 }) + }) + it('publishes only off and omits the wire effort when thinking is disabled', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const ctx = await harness(server.url, { thinking: 'disabled' }) @@ -666,7 +681,8 @@ describe('plugin registration and config', () => { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', - context: { contextWindow: 256_000 }, + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, reasoning: { efforts: [ { id: ReasoningEffortId('off'), name: 'Off' }, @@ -795,7 +811,10 @@ describe('plugin registration and config', () => { description: 'Higher reasoning budget', }) await expect(ctx.llm.resolveModelInfo('deepseek', 'arbitrary-unlisted')) - .resolves.not.toHaveProperty('context') + .resolves.toMatchObject({ + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + }) }) it('uses exact model capacity before the adapter-wide default', async () => { @@ -880,6 +899,26 @@ describe('plugin registration and config', () => { }, ) + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid adapter-wide maxTokens %s', + async (maxTokens) => { + expect(() => new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + maxTokens, + })).toThrow(/maxTokens must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + maxTokens, + })).rejects.toThrow(/maxTokens/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 539dec3258..9a296ea8cb 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -170,6 +170,13 @@ describe('serializeRequest', () => { expect(wire.stop).toEqual(['END']) }) + it('uses the adapter maxTokens default only when the request omits a cap', () => { + expect(serializeRequest(request({ messages: history }), {}, 256_000).max_tokens) + .toBe(256_000) + expect(serializeRequest(request({ messages: history, maxTokens: 8_192 }), {}, 256_000).max_tokens) + .toBe(8_192) + }) + it('maps tools to the wire function shape', () => { const wire = serializeRequest(request({ messages: history, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 49ff6d7c48..4d637c49cc 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: d343449d1530bf70a3a8c57f883894e29c42d18f -README.zh.md: 4dc4a0ca06378116d05fdb4b9b048738930511fd +README.md: 3ffcfb59fa7d3e3077a59d0b0c8ebd9afa590e7e +README.zh.md: df44b2bad1d5fa5c03dff86de584bfff63dbf297 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d343449d15..3ffcfb59fa 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -14,8 +14,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. -- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. -- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize an adapter-configured default without clamping. +- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. +- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize adapter-configured call defaults without clamping. - `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config and capture its current adapter registration as one cancellable, one-shot call. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. @@ -23,9 +23,9 @@ An adapter registry plus a single streaming call surface, interceptable via a wa Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. -Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`. +Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context`, `defaultMaxTokens`, or `reasoning` fields preserve unknown capacity, provider-owned output defaults, or unavailable reasoning capability. Invalid identity, context, output default, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, `INVALID_MODEL_MAX_TOKENS`, or `INVALID_MODEL_REASONING`. -Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. +`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. ### Events @@ -35,7 +35,7 @@ Reasoning identifiers are opaque adapter-owned strings rather than a core enum. ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use bounded normal retry policy, use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, an output default, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use bounded normal retry policy, use the route and model ids as names, advertise no models, and return no capacity, output default, or reasoning metadata. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Messages (`message.ts`) and content blocks (`types.ts`) @@ -48,7 +48,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### Call configuration (`call-config.ts`) -`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates and defaults it under the turn signal, and the loop logs the effective value before using the prepared call's registration-bound stream. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. +`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates it and materializes adapter defaults under the turn signal, and the loop logs the effective value before using the prepared call's registration-bound stream. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. ### App attribution (`attribution.ts`) diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 4dc4a0ca06..df44b2bad1 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -14,8 +14,8 @@ - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 -- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理(reasoning)元数据;异步适配器可选地支持取消。 -- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。 +- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有精确路由的适配器解析经校验的确切模型身份,以及可用上下文、输出默认值和推理(reasoning)元数据;异步适配器可选地支持取消。 +- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的调用默认值,但不自动调整。 - `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。 - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` 将一次模型调用流式输出为原始分片(token 级增量)。消费方使用 `BlockAssembler` 将分片组装为块/消息。 @@ -23,9 +23,9 @@ 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 -确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 +确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context`、`defaultMaxTokens` 或 `reasoning` 字段会分别保留未知容量、提供方持有的输出默认值或不可用的推理能力。无效的身份、上下文、输出默认值或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT`、`INVALID_MODEL_MAX_TOKENS` 或 `INVALID_MODEL_REASONING` 失败。 -推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 +`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器持有的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 ### 事件 @@ -35,7 +35,7 @@ ### 扩展点 -- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 以提供由提供方持有的恢复配置,覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界的 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量或推理元数据。 +- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 以提供由提供方持有的恢复配置,覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量、输出默认值或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界的 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量、输出默认值或推理元数据。 - 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。发出分片后重试的包装层没有持久尝试边界;因此已发布 agent 重试策略改用 `agent/request-error`。 ### 消息(`message.ts`)与内容块(`types.ts`) @@ -48,7 +48,7 @@ ### 调用配置(`call-config.ts`) -`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验并填入默认值,loop 随后记录生效值,再使用已准备调用中与注册绑定的流。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。 +`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值,再使用已准备调用中与注册绑定的流。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。 ### 应用归因(`attribution.ts`) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 1fb4443af0..0d0fea78ca 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -166,7 +166,7 @@ export abstract class LlmAdapter { * @param model - exact model id passed to {@link GenerateOptions.model}. * @param _signal - cancellation for this exact-model lookup; asynchronous * implementations must settle promptly after it aborts. - * @returns provider/model identity plus any context and reasoning metadata. + * @returns provider/model identity plus any context, call-default, and reasoning metadata. */ resolveModel( provider: string, @@ -331,12 +331,21 @@ export class LlmService extends Service { 'INVALID_MODEL_CONTEXT', ) } + const defaultMaxTokens = resolved.defaultMaxTokens + if (defaultMaxTokens !== undefined + && (!Number.isSafeInteger(defaultMaxTokens) || defaultMaxTokens <= 0)) { + throw new LlmError( + `adapter returned invalid default maxTokens for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_MAX_TOKENS', + ) + } const info: LlmResolvedModelInfo = { provider, id: model, name: resolved.name, ...resolved.description === undefined ? {} : { description: resolved.description }, ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } }, + ...defaultMaxTokens === undefined ? {} : { defaultMaxTokens }, } const reasoning = resolved.reasoning if (reasoning === undefined) return info @@ -385,7 +394,7 @@ export class LlmService extends Service { /** * Validate a conversation call config against its exact model capability and - * materialize an adapter-configured default. Unsupported explicit efforts + * materialize adapter-configured defaults. Unsupported explicit efforts * reject before provider I/O; no clamping or aliasing is performed. This * standalone query does not bind a later dispatch; use {@link prepareCall} * when logging and streaming must share one adapter registration. @@ -402,8 +411,12 @@ export class LlmService extends Service { config: LlmCallConfig, signal?: AbortSignal, ): Promise { - const reasoning = (await this.resolveModelInfoFor(registration, config.model, signal)).reasoning - const requested = config.reasoningEffort + const info = await this.resolveModelInfoFor(registration, config.model, signal) + const defaulted = config.maxTokens === undefined && info.defaultMaxTokens !== undefined + ? { ...config, maxTokens: info.defaultMaxTokens } + : config + const reasoning = info.reasoning + const requested = defaulted.reasoningEffort if (reasoning === undefined) { if (requested !== undefined) { throw new LlmError( @@ -411,17 +424,17 @@ export class LlmService extends Service { 'UNSUPPORTED_REASONING_EFFORT', ) } - return config + return defaulted } const effective = requested ?? reasoning.defaultEffort - if (effective === undefined) return config + if (effective === undefined) return defaulted if (!reasoning.efforts.some(effort => effort.id === effective)) { throw new LlmError( `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`, 'UNSUPPORTED_REASONING_EFFORT', ) } - return requested === effective ? config : { ...config, reasoningEffort: effective } + return requested === effective ? defaulted : { ...defaulted, reasoningEffort: effective } } /** diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 4e6e0eabe2..4e0ee33497 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -162,6 +162,8 @@ export interface LlmModelReasoningInfo { export interface LlmResolvedModelInfo extends LlmModelInfo { /** Provider-owned context capacity when known. */ context?: LlmModelContext + /** Adapter-configured per-request output cap materialized when callers omit one. */ + defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 1afb3c4b6d..1140d720ed 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -60,6 +60,7 @@ class CatalogAdapter extends ScriptedAdapter { private readonly models: readonly LlmModelInfo[], private readonly contexts: Readonly> = {}, private readonly reasoning: Readonly> = {}, + private readonly defaultMaxTokens: Readonly> = {}, ) { super(SCRIPT) } @@ -82,6 +83,7 @@ class CatalogAdapter extends ScriptedAdapter { name: model, ...this.contexts[model] === undefined ? {} : { context: this.contexts[model] }, ...this.reasoning[model] === undefined ? {} : { reasoning: this.reasoning[model] }, + ...this.defaultMaxTokens[model] === undefined ? {} : { defaultMaxTokens: this.defaultMaxTokens[model] }, }) } } @@ -920,6 +922,46 @@ describe('LlmService', () => { await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit) }) + it('materializes an adapter-owned maxTokens default while preserving an explicit cap', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + {}, + {}, + { model: 256_000 }, + )) + + await expect(ctx.llm.resolveModelInfo('route', 'model')).resolves.toMatchObject({ + defaultMaxTokens: 256_000, + }) + await expect(ctx.llm.resolveCallConfig({ provider: 'route', model: 'model' })).resolves.toEqual({ + provider: 'route', + model: 'model', + maxTokens: 256_000, + }) + const explicit = { provider: 'route', model: 'model', maxTokens: 8_192 } + await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit) + }) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid adapter-owned default maxTokens %s', + async (defaultMaxTokens) => { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new class extends ScriptedAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, defaultMaxTokens }) + } + }(SCRIPT) + ctx.llm.registerAdapter(['route'], adapter) + + await expect(ctx.llm.resolveModelInfo('route', 'model')) + .rejects.toMatchObject({ code: 'INVALID_MODEL_MAX_TOKENS' }) + }, + ) + it.each([ [{ efforts: [] }, 'empty effort list'], [{ efforts: [{ id: '', name: 'Empty' }] }, 'empty id'], diff --git a/packages/sdk/sdk-protocol/README.i18n.yaml b/packages/sdk/sdk-protocol/README.i18n.yaml index 7eec4f64dd..b868126d59 100644 --- a/packages/sdk/sdk-protocol/README.i18n.yaml +++ b/packages/sdk/sdk-protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/sdk-protocol/README.md -README.md: 62b26d4a82d358fa4efcb7ab84036e5f4848057f -README.zh.md: 11677c6119c7da407d95ee38ad9f8f7a552c15de +README.md: 6dfc749bb0610f2c94e1a23fa395a428e47126bc +README.zh.md: 2c2284dcdac3029ffaf6cac65e4c1247a2328939 diff --git a/packages/sdk/sdk-protocol/README.md b/packages/sdk/sdk-protocol/README.md index 62b26d4a82..6dfc749bb0 100644 --- a/packages/sdk/sdk-protocol/README.md +++ b/packages/sdk/sdk-protocol/README.md @@ -22,7 +22,7 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) | -`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission leaves the provider default in control. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. +`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. ## Model Experience diff --git a/packages/sdk/sdk-protocol/README.zh.md b/packages/sdk/sdk-protocol/README.zh.md index 11677c6119..2c2284dcda 100644 --- a/packages/sdk/sdk-protocol/README.zh.md +++ b/packages/sdk/sdk-protocol/README.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按 | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) | -`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent(智能体)及其进程内后代的每次对话模型输出;省略时由提供方默认值控制。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。 +`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent(智能体)及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。 ## 模型体验 diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index 296617f484..0e17f0c3dc 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md -README.md: e904ce3c09a1b44f8f5a0072b9ca85812898e74f -README.zh.md: cec89ad9a65c68163fc136fe7b04cb135c57fb51 +README.md: 4ce6011841bff06b5f3336814aa48f23b3ccceaf +README.zh.md: 64194415afec342a00f3bc4670ac073127ace7b2 diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index e904ce3c09..4ce6011841 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -32,7 +32,7 @@ The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/ | `cwd` | parent session cwd | Working-directory override; same validation as [`subagent-acp`](../subagent-acp/README.md). | | `provider` | `deepseek` | Provider route sent in the child's `initialize`. | | `model` | `deepseek-v4-flash` | Model sent in the child's `initialize`. | -| `maxTokens` | provider default | Per-request output-token cap sent in the child's `initialize`; it applies to the child root agent and its in-process descendants. | +| `maxTokens` | adapter/provider route default | Per-request output-token cap sent in the child's `initialize`; it applies to the child root agent and its in-process descendants. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment (e.g. the child's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). | | `shutdownTimeoutMs` | `1000` | Bound on the protocol `shutdown` exchange during dispose. | | `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. | diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index cec89ad9a6..64194415af 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -32,7 +32,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte | `cwd` | 父会话 cwd | 工作目录覆盖;校验规则与 [`subagent-acp`](../subagent-acp/README.md) 相同。 | | `provider` | `deepseek` | 写入子进程 `initialize` 的提供方路由。 | | `model` | `deepseek-v4-flash` | 写入子进程 `initialize` 的模型。 | -| `maxTokens` | 提供方默认值 | 写入子进程 `initialize` 的单次请求输出 token 上限;对子运行时的根 agent 及其进程内后代生效。 | +| `maxTokens` | 适配器/提供方路由默认值 | 写入子进程 `initialize` 的单次请求输出 token 上限;对子运行时的根 agent 及其进程内后代生效。 | | `env` | `{}` | 在凭据擦除后的父环境之上叠加的显式子环境(例如子进程自己的 `DEEPSEEK_API_KEY`,或 `DSH_CORDIS_CONFIG`)。 | | `shutdownTimeoutMs` | `1000` | dispose 期间协议 `shutdown` 交换的时限。 | | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限。 | diff --git a/packages/ui/jsonrpc/README.i18n.yaml b/packages/ui/jsonrpc/README.i18n.yaml index 324f8c4d99..657c27d231 100644 --- a/packages/ui/jsonrpc/README.i18n.yaml +++ b/packages/ui/jsonrpc/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md -README.md: b1219ba10269fc7d046da22c280ff1b91424a5ae -README.zh.md: 1c27f5edf2f1f172aa6303697b17e2e77a65842a +README.md: 7e70c9e099d5b66196754f5859ba95871507516b +README.zh.md: a38db1e81e33002e981d17abeb1dd4e4d2f6e337 diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index b1219ba102..7e70c9e099 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -22,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no cap and preserves provider defaults. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`. ## Model Experience diff --git a/packages/ui/jsonrpc/README.zh.md b/packages/ui/jsonrpc/README.zh.md index 1c27f5edf2..a38db1e81e 100644 --- a/packages/ui/jsonrpc/README.zh.md +++ b/packages/ui/jsonrpc/README.zh.md @@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 协议说明 -`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送上限并保留提供方默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。 +`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。 ## 模型体验 From 5fd34f9109bfc61e74980a1a790104f77c99a8bc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 21:49:58 +0800 Subject: [PATCH 07/22] fix(agent-loop): rematerialize adapter defaults --- ...adapter-owned-max-token-defaults.i18n.yaml | 4 +- ...-07-30-adapter-owned-max-token-defaults.md | 6 +- ...-30-adapter-owned-max-token-defaults.zh.md | 6 +- apps/cli/config/tui.cordis.yml | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 15 +++- docs/core-data-structures/core.zh.md | 15 +++- .../llm-streaming.i18n.yaml | 4 +- docs/core-data-structures/llm-streaming.md | 2 + docs/core-data-structures/llm-streaming.zh.md | 2 + docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 4 +- docs/core-data-structures/session.zh.md | 4 +- docs/persistence-catalog.md | 28 +++---- examples/headless-agent/cordis.yml | 2 +- .../headless-agent/tests/headless.snapshot.ts | 10 ++- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 4 +- packages/core/agent-loop/src/agent.ts | 22 ++++-- .../tests/request-reconstruction.spec.ts | 79 ++++++++++++++++++- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- packages/core/session/src/index.ts | 29 ++++++- packages/core/session/src/request-header.ts | 11 ++- packages/core/session/src/types.ts | 3 + .../core/session/tests/request-header.spec.ts | 29 ++++++- packages/core/session/tests/session.spec.ts | 34 ++++++++ packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 +- packages/llm/llm-deepseek/README.zh.md | 4 +- packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-deepseek/src/serialize.ts | 5 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 7 -- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 4 +- packages/llm/llm/README.zh.md | 4 +- packages/llm/llm/src/call-config.ts | 9 +++ packages/llm/llm/src/index.ts | 20 +++-- packages/llm/llm/tests/service.spec.ts | 15 +++- scripts/type-equiv.manifest.json | 5 ++ 47 files changed, 348 insertions(+), 100 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml index 0cd4e5f83e..752dc0f1aa 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md -2026-07-30-adapter-owned-max-token-defaults.md: c6fc9f014124607a3e2c3520f9f3b9023b77935f -2026-07-30-adapter-owned-max-token-defaults.zh.md: e0670f43eb5f27c2307c734e01a5b6718c09a67e +2026-07-30-adapter-owned-max-token-defaults.md: a522848fd4482f84859e587505b6a5e6f5c72d60 +2026-07-30-adapter-owned-max-token-defaults.zh.md: 8db6a06199fc1c4e73c86492d12dc86edafe8c7e diff --git a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md index c6fc9f0141..a522848fd4 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md +++ b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md @@ -10,9 +10,9 @@ An LLM adapter could serialize an explicit `GenerateOptions.maxTokens`, but its ## Decision -`LlmResolvedModelInfo.defaultMaxTokens` carries an optional adapter-configured per-request output cap for one exact provider/model route. `LlmService` validates it as a positive safe integer and materializes it into `LlmCallConfig.maxTokens` only when the caller omitted a value. Explicit request or Agent options therefore win without clamping. +`LlmResolvedModelInfo.defaultMaxTokens` carries an optional adapter-configured per-request output cap for one exact provider/model route. `LlmService` validates it as a positive safe integer and materializes it into `LlmCallConfig.maxTokens` only when the caller omitted a value. A prepared call identifies materialized `maxTokens` and `reasoningEffort` fields as adapter defaults; explicit request or Agent options remain unmarked and therefore win without clamping. -The agent loop continues to prepare calls before logging `request/header`, so an adapter default becomes a durable request fact before dispatch. Direct `LlmService.stream()` calls resolve the same default at the final adapter boundary. The field is a request default rather than a hard model output limit; adapters that preserve provider-owned defaults omit it. +The agent loop continues to prepare calls before logging `request/header`, so the effective config and its adapter-default provenance become durable request facts before dispatch. Before the next `agent/request` waterfall, the loop removes marked fields from the proposal; exact-model resolution then materializes the current route's defaults again. A provider/model switch therefore cannot mistake a previous adapter's default for an explicit override, while explicit conversation values persist. Direct `LlmService.stream()` calls resolve the same default at the final adapter boundary. The field is a request default rather than a hard model output limit; adapters that preserve provider-owned defaults omit it. The native DeepSeek adapter exposes `maxTokens` in Cordis config with a 256,000-token default and maps the effective value to `max_tokens`. Its default context capacity is 1,000,000 tokens: both built-in V4 entries publish that exact capacity, while configured entries without capacity and unlisted pass-through ids inherit the same adapter-wide fallback. @@ -28,6 +28,6 @@ The native DeepSeek adapter exposes `maxTokens` in Cordis config with a 256,000- ## Consequences -DeepSeek conversations send `max_tokens: 256000` by default, and the same value appears in the session request header. Deployments can change the adapter default through `llm-deepseek.config.maxTokens`; per-agent and per-request values override it. Other adapters retain their existing behavior until they intentionally publish `defaultMaxTokens`. +DeepSeek conversations send `max_tokens: 256000` by default, and the same value plus its adapter provenance appear in the session request header. Deployments can change the adapter default through `llm-deepseek.config.maxTokens`; per-agent and per-request values override it. Changing the route rematerializes the new exact adapter's default instead of carrying DeepSeek's derived value forward. Other adapters retain their existing behavior until they intentionally publish `defaultMaxTokens`. The 256,000-token output budget reserves a large part of the one-million-token context on endpoints that pre-allocate requested output. Deployments whose gateway or model supports a smaller budget must lower `maxTokens`; the explicit configuration is preferable to an undocumented provider fallback. diff --git a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md index e0670f43eb..8db6a06199 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.zh.md @@ -10,9 +10,9 @@ LLM(大语言模型)适配器可以序列化显式的 `GenerateOptions.maxTo ## Decision -`LlmResolvedModelInfo.defaultMaxTokens` 携带一条确切提供方/模型路由的可选单次请求输出上限,该值由适配器配置。`LlmService` 将其校验为正安全整数,并且仅在调用方省略值时才填入 `LlmCallConfig.maxTokens`。因此,显式请求值或 Agent 选项优先,且不会被自动调整。 +`LlmResolvedModelInfo.defaultMaxTokens` 携带一条确切提供方/模型路由的可选单次请求输出上限,该值由适配器配置。`LlmService` 将其校验为正安全整数,并且仅在调用方省略值时才填入 `LlmCallConfig.maxTokens`。准备后的调用会将已填入的 `maxTokens` 和 `reasoningEffort` 字段标记为适配器默认值;显式请求值或 Agent 选项不带该标记,因此优先且不会被自动调整。 -agent loop 仍在记录 `request/header` 前准备调用,因此适配器默认值会在分派前成为持久请求事实。直接调用 `LlmService.stream()` 时,也会在最终适配器边界解析同一默认值。该字段是请求默认值,而非模型输出硬上限;保留提供方持有默认值的适配器会省略它。 +agent loop 仍在记录 `request/header` 前准备调用,因此生效配置及其适配器默认值来源会在分派前成为持久请求事实。下一次 `agent/request` waterfall(瀑布式事件)前,agent loop 会从提议中移除带标记字段,随后精确模型解析会再次填入当前路由的默认值。因此,切换提供方/模型不会把前一个适配器的默认值误当成显式覆盖,而显式对话值则会保留。直接调用 `LlmService.stream()` 时,也会在最终适配器边界解析同一默认值。该字段是请求默认值,而非模型输出硬上限;保留提供方持有默认值的适配器会省略它。 原生 DeepSeek 适配器在 Cordis 配置中公开 `maxTokens`,默认值为 256,000 token,并将生效值映射为 `max_tokens`。其默认上下文容量为 1,000,000 token:两个内置 V4 配置项均公布这一精确容量;不含容量的已配置项和未列出的原样传递 id 则继承同一个适配器级回退值。 @@ -28,6 +28,6 @@ agent loop 仍在记录 `request/header` 前准备调用,因此适配器默认 ## Consequences -DeepSeek 对话默认发送 `max_tokens: 256000`,会话请求 header 中也会出现相同的值。部署可以通过 `llm-deepseek.config.maxTokens` 更改适配器默认值;每个 agent 和每次请求的值都会覆盖它。其他适配器会保留现有行为,直至主动公布 `defaultMaxTokens`。 +DeepSeek 对话默认发送 `max_tokens: 256000`,会话请求 header 中也会出现相同的值及其适配器来源。部署可以通过 `llm-deepseek.config.maxTokens` 更改适配器默认值;每个 agent 和每次请求的值都会覆盖它。更改路由会重新填入新的精确适配器默认值,而不是继续沿用 DeepSeek 派生出的值。其他适配器会保留现有行为,直至主动公布 `defaultMaxTokens`。 对于预分配请求输出的端点,256,000 token 的输出预算会占用 1,000,000 token 上下文中的很大部分。如果部署使用的 gateway 或模型仅支持较小预算,则必须调低 `maxTokens`;显式配置优于未记录的提供方回退值。 diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index b271a11f05..f7475a8378 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -37,7 +37,7 @@ factual. # Shipped default: full thinking at max effort on every request. Exact-model -# resolution materializes the effort before the request header is logged. +# resolution materializes request defaults before the request header is logged. - id: llm-deepseek config: apiKey: !!js process.env.DEEPSEEK_API_KEY diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index dfff50708f..c3b5c6d8f8 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 1fd9bd128d1bcc0dd91d46131981ea4fc331bd74 -architecture.zh.md: 8521f09c6e415f9f8d1c0a44f7534b59c876decc +architecture.md: ef93b47702cdc8a2a6c68dc07edd629f0ab43170 +architecture.zh.md: 4f1f05a3ce10f5de7edf192ff8d04e43a4dcb5c9 diff --git a/docs/architecture.md b/docs/architecture.md index 1fd9bd128d..ef93b47702 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ forever: assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + agent/request (config only) -> prepare adapter defaults/provenance under turn signal -> log request/header -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: @@ -143,7 +143,7 @@ Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, an The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from this stream. -**Model-visible ⟺ logged**: messages at `step/start` plus the folded `request/header` reconstruct every request; package-owned `dsh-agent-loop/invariant` can assert this through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: messages at `step/start` plus the folded `request/header` reconstruct every request; the header also marks adapter-materialized defaults so the next proposal can discard them and resolve the selected route without losing explicit conversation settings. Package-owned `dsh-agent-loop/invariant` can assert reconstructability through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede each request and top-level tool dispatch, then follow `turn/end` before another queued turn or idle observation. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 8521f09c6e..4f1f05a3ce 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -94,7 +94,7 @@ forever: assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + agent/request (config only) -> prepare adapter defaults/provenance under turn signal -> log request/header -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: @@ -143,7 +143,7 @@ idle inject: 会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自该事件流。 -**模型可见 ⟺ 已记录**:`step/start` 时的消息与折叠后的 `request/header` 可以重建每个请求;该包的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已记录**:`step/start` 时的消息与折叠后的 `request/header` 可以重建每个请求;该 header 还会标记适配器填入的默认值,使下一次提议可以丢弃这些值并解析所选路由,同时不丢失显式对话设置。该包的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言可重建性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于每次请求与顶层工具分发之前,并在 `turn/end` 之后、处理另一个已排队轮次或观察到空闲状态之前执行。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 97500f2747..a2cb4913f1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -820,7 +820,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:191`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:193`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:713`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:738`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 74bac3a63c..369bfe36dd 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 782d51b5178276ca1cb313607165b17b8e5d6a02 -core.zh.md: d1c06447d9a0675d87eb263d7486b64cd4677373 +core.md: 418d4102f9d39109069aa9ae6eb4a33c2f440b46 +core.zh.md: 09a1790651b02cb4977135ba9d656443eb1c52d3 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 782d51b517..418d4102f9 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -340,9 +340,9 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). +The loop builds each request from logged state. `EpochHeader` records call config, adapter-default provenance, rendered prompt, and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus provenance under the turn signal. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. @@ -365,6 +365,17 @@ interface LlmCallConfig { } ``` +```ts type-equiv +/** + * Effective config fields supplied by exact-model adapter resolution rather + * than by the caller's request proposal. + */ +interface LlmCallConfigAdapterDefaults { + reasoningEffort?: true + maxTokens?: true +} +``` + ## Sessions A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. The event vocabulary derives from `SessionEventMap`: diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d1c06447d9..09a1790651 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -346,9 +346,9 @@ interface ToolSchema { ### 请求信封:`LlmCallConfig` 与记录的 header -循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词以及权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 +循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、适配器默认值来源、渲染后的提示词以及权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置及其来源。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 @@ -371,6 +371,17 @@ interface LlmCallConfig { } ``` +```ts type-equiv +/** + * Effective config fields supplied by exact-model adapter resolution rather + * than by the caller's request proposal. + */ +interface LlmCallConfigAdapterDefaults { + reasoningEffort?: true + maxTokens?: true +} +``` + ## 会话 `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。事件词汇从 `SessionEventMap` 派生: diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 61594c9066..7e1ba8b7bb 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/llm-streaming.md -llm-streaming.md: d9c1772e2fb56de2f240b4e62fdc2e1aa12ae787 -llm-streaming.zh.md: 824b7c46186f2e330ad2a3aafa7a046759ab9a89 +llm-streaming.md: e7500a7985ea1916e206c41e05855701b48fcf00 +llm-streaming.zh.md: 2b61815f2730afdfb93bc06b8ee8925d2f4cac25 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index d9c1772e2f..e7500a7985 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -169,6 +169,8 @@ declare class BlockAssembler { interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Config fields materialized by the captured adapter rather than proposed by the caller. */ + readonly adapterDefaults: LlmCallConfigAdapterDefaults /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 824b7c4618..2b61815f27 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -169,6 +169,8 @@ declare class BlockAssembler { interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Config fields materialized by the captured adapter rather than proposed by the caller. */ + readonly adapterDefaults: LlmCallConfigAdapterDefaults /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 7c99825d0a..8297d3779a 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 769d5db301e3e81664c732ab1685c859a00cceb2 -session.zh.md: 7af459949eda1b939596c20adf1c9e55f6d2b2b4 +session.md: d7cd2c216c35a89024a312805759929b26edb53d +session.zh.md: 1a163f96872ba18f230d6aa395e86c0f4c56240d diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 769d5db301..d7cd2c216c 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -144,7 +144,7 @@ interface TodoItem { ### The request header event: `request/header` -The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. +The request envelope — the `EpochHeader` (call config + adapter-default provenance + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. ```ts type-equiv /** @@ -155,6 +155,8 @@ The request envelope — the `EpochHeader` (call config + rendered system prompt interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig + /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */ + adapterDefaults?: LlmCallConfigAdapterDefaults /** Rendered system prompt text; absent for a system-less request. */ system?: string /** Assembled tool schemas; absent for a tool-less request. */ diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 7af459949e..1a163f9687 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -146,7 +146,7 @@ interface TodoItem { ### 请求头事件:`request/header` -请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 +请求信封(即 `EpochHeader`:调用配置 + 适配器默认值来源 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 ```ts type-equiv /** @@ -157,6 +157,8 @@ interface TodoItem { interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig + /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */ + adapterDefaults?: LlmCallConfigAdapterDefaults /** Rendered system prompt text; absent for a system-less request. */ system?: string /** Assembled tool schemas; absent for a tool-less request. */ diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 524ba6ffd7..4d4faa42e3 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) ## Events @@ -154,7 +154,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -170,7 +170,7 @@ Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) ### `command/*` @@ -379,7 +379,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/s 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -432,7 +432,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -468,7 +468,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'steering/message': { turn: number; message: UserMessage } ``` -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) ### `step/*` @@ -479,7 +479,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -488,7 +488,7 @@ Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) ### `todo/*` @@ -501,7 +501,7 @@ Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) ### `tool/*` @@ -518,7 +518,7 @@ Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -591,7 +591,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) ### `turn/*` @@ -609,7 +609,7 @@ Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -622,7 +622,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) ### `user/*` @@ -640,4 +640,4 @@ Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 038a2da7f6..335add389c 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -5,7 +5,7 @@ # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed # twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). # Shipped default: full thinking at max effort on every request. Exact-model -# resolution materializes the effort before the request header is logged. +# resolution materializes request defaults before the request header is logged. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 511d41e6b2..e51b2ab70f 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -270,7 +270,7 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') expect(server.requests).toHaveLength(1) expect(server.requests[0]?.max_tokens).toBe(256_000) - const config = parseJsonl(result.stdout) + const header = (parseJsonl(result.stdout) .map(record => record.event) .find((event): event is JsonObject => ( event !== null @@ -278,8 +278,8 @@ describe('headless stream-json snapshots', () => { && !Array.isArray(event) && 'type' in event && event.type === 'request/header' - ))?.data as JsonObject | undefined - expect((config?.header as JsonObject | undefined)?.config).toMatchInlineSnapshot(` + ))?.data as JsonObject | undefined)?.header as JsonObject | undefined + expect(header?.config).toMatchInlineSnapshot(` { "maxTokens": 256000, "model": "deepseek-v4-flash", @@ -287,6 +287,10 @@ describe('headless stream-json snapshots', () => { "reasoningEffort": "off", } `) + expect(header?.adapterDefaults).toEqual({ + maxTokens: true, + reasoningEffort: true, + }) } finally { await server.close() } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c12905ba85..f4aa70420c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1757,7 +1757,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'EpochHeader', - declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n}', + declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n}', }, { name: 'FileDiff', @@ -1911,6 +1911,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', }, + { + name: 'LlmCallConfigAdapterDefaults', + declaration: 'export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n}', + }, { name: 'LlmFailure', declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}', @@ -1969,7 +1973,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreparedLlmCall', - declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'PreparedReferencedMessage', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 3a82c693ca..2649d4fde0 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: a1617a1ef871f61157e0d70a06d055168170dced -README.zh.md: 6ba945a41e700331929dabb557802c14256921fb +README.md: afc00f1ecdd225f22da46b95827259a50c719766 +README.zh.md: 63aaab3b5af32b9bad70d74fbd57c8bd62c2ef7d diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a1617a1ef8..afc00f1ecd 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -65,7 +65,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. -After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. +After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance applies the same provenance rule when resuming. Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 6ba945a41e..63aaab3b5a 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -65,7 +65,7 @@ interface Config { 每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的分片溯源(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。 -在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 +在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。 插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 @@ -92,7 +92,7 @@ interface Config { #### Token 影响 -每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall(瀑布式事件)可以改变最终请求,并使其监听器负责保持协议连贯。 +每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall 可以改变最终请求,并使其监听器负责保持协议连贯。 #### KV Cache 影响 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1a317aa342..460deded9f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -44,7 +44,7 @@ import { } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' -import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session' +import type { AssistantMessage, EpochHeader, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' @@ -54,6 +54,15 @@ type StepOutcome = | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean } | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } +/** Remove adapter-derived values before plugins propose the next request config. */ +function requestProposal(header: EpochHeader): LlmCallConfig { + if (header.adapterDefaults === undefined) return header.config + const proposal = { ...header.config } + if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort + if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens + return proposal +} + /** * The concrete {@link Agent}: each `run()` owns one turn and repeats model * steps while tools or steering require another request. @@ -615,19 +624,21 @@ export class ReactLoopAgent implements Agent { ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> { const { session } = this - // A loop instance starts from its declared route, restoring only an opaque - // effort owned by that exact model. Later steps fold the config it logged. - const persistedConfig = session.requestHeader()?.config + // A loop instance starts from its declared route, restoring only an explicit + // effort owned by that exact model. Later steps re-resolve marked defaults. + const persistedHeader = session.requestHeader() + const persistedConfig = persistedHeader?.config const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' } const reasoningEffort = persistedConfig?.provider === route.provider && persistedConfig.model === route.model + && persistedHeader?.adapterDefaults?.reasoningEffort !== true ? persistedConfig.reasoningEffort : undefined const maxTokens = this.options.maxTokens const seedConfig = deepFreeze(structuredClone( this.requestHeaderLogged // oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds - ? persistedConfig! + ? requestProposal(persistedHeader!) : { ...route, ...reasoningEffort === undefined ? {} : { reasoningEffort }, @@ -657,6 +668,7 @@ export class ReactLoopAgent implements Agent { const header = canonicalHeader({ config, + ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults }, ...system ? { system } : {}, ...tools.length > 0 ? { tools } : {}, }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index f27aeabe7b..2fc80c32c1 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -18,6 +18,13 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, persona = 'stable base') { + return harnessRoutes([['mock', adapter]], persona) +} + +async function harnessRoutes( + adapters: readonly (readonly [provider: string, adapter: MockAdapter])[], + persona = 'stable base', +) { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -25,7 +32,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) + for (const [provider, adapter] of adapters) ctx.llm.registerAdapter([provider], adapter) return ctx } @@ -134,6 +141,10 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ReasoningEffortId('max'), ]) + expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([ + { reasoningEffort: true }, + undefined, + ]) expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change']) for (const [model, effort] of [ @@ -172,6 +183,72 @@ describe('request stability across the loop', () => { expect(adapter.requests[0]?.maxTokens).toBe(256_000) const header = agent.session.events.find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.maxTokens).toBe(256_000) + expect(header?.type === 'request/header' && header.data.header.adapterDefaults) + .toEqual({ maxTokens: true }) + }) + + it('rematerializes the selected adapter maxTokens default after a provider switch', async () => { + const deepseek = new MockAdapter([textResponse('deepseek')], undefined, 256_000) + const other = new MockAdapter([textResponse('other')], undefined, 8_192) + const ctx = await harnessRoutes([ + ['deepseek', deepseek], + ['other', other], + ]) + const agent = ctx.agentLoop.create(SessionId('adapter-max-tokens-switch'), { + provider: 'deepseek', + model: 'deepseek-model', + }) + ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + const config = await next() + return turn === 2 + ? { ...config, provider: 'other', model: 'other-model' } + : config + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second') + await waitForIdle(ctx, agent) + + expect(deepseek.requests[0]?.maxTokens).toBe(256_000) + expect(other.requests[0]?.maxTokens).toBe(8_192) + const headers = agent.session.events.filter(event => event.type === 'request/header') + expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([256_000, 8_192]) + expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([ + { maxTokens: true }, + { maxTokens: true }, + ]) + }) + + it('preserves an explicit agent maxTokens cap across a provider switch', async () => { + const deepseek = new MockAdapter([textResponse('deepseek')], undefined, 256_000) + const other = new MockAdapter([textResponse('other')], undefined, 8_192) + const ctx = await harnessRoutes([ + ['deepseek', deepseek], + ['other', other], + ]) + const agent = ctx.agentLoop.create(SessionId('explicit-max-tokens-switch'), { + provider: 'deepseek', + model: 'deepseek-model', + maxTokens: 4_096, + }) + ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + const config = await next() + return turn === 2 + ? { ...config, provider: 'other', model: 'other-model' } + : config + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second') + await waitForIdle(ctx, agent) + + expect(deepseek.requests[0]?.maxTokens).toBe(4_096) + expect(other.requests[0]?.maxTokens).toBe(4_096) + const headers = agent.session.events.filter(event => event.type === 'request/header') + expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([4_096, 4_096]) + expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([undefined, undefined]) }) it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => { diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 9fb097d9fb..73ba153907 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412 -README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989 +README.md: a4944fd974fda5ee1bd6ecadf29ef5fa322e9dc3 +README.zh.md: 3578ea3aa48382609e075518b0f8a7f8851761e4 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index a9b6905dcf..a4944fd974 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -63,7 +63,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ ### Request-header reconstruction (`request-header.ts`) -`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). +`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index f1a5e97e32..3578ea3aa4 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -63,7 +63,7 @@ ### 请求头重建(`request-header.ts`) -`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 +`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 `user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index a403e11df1..3dfb83a890 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -201,13 +201,18 @@ function assertCurrentLlmShape(event: Record, index: number): v : undefined if (event['type'] === 'request/header') { const header = record?.['header'] - const config = typeof header === 'object' && header !== null ? (header as Record)['config'] : undefined + const headerRecord = typeof header === 'object' && header !== null && !Array.isArray(header) + ? header as Record + : undefined + const config = headerRecord?.['config'] if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`) - const reasoningEffort = (config as Record)['reasoningEffort'] + const configRecord = config as Record + const reasoningEffort = configRecord['reasoningEffort'] if (reasoningEffort !== undefined && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) { throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`) } + assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index) } const type = event['type'] if (type !== 'user/message' && type !== 'assistant/message' @@ -215,6 +220,26 @@ function assertCurrentLlmShape(event: Record, index: number): v assertMessageEventShape(event, `seed ${type} at index ${index}`) } +/** Validate adapter-default provenance imported from a durable request header. */ +function assertAdapterDefaults( + value: unknown, + config: Record, + index: number, +): void { + if (value === undefined) return + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`) + } + const defaults = value as Record + const allowed = new Set(['reasoningEffort', 'maxTokens']) + if (Object.keys(defaults).some(key => !allowed.has(key)) + || Object.values(defaults).some(marker => marker !== true) + || defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined + || defaults['maxTokens'] === true && config['maxTokens'] === undefined) { + throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`) + } +} + /** Validate only the event-specific invariants needed to safely replay a message. */ function assertMessageEventShape(event: Record, subject: string): void { const type = event['type'] diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts index ad2b61faed..ef67569139 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -19,8 +19,12 @@ import type { EpochHeader, SessionEvent } from './types.ts' * @returns the canonical header. */ export function canonicalHeader(header: EpochHeader): EpochHeader { + const adapterDefaults = header.adapterDefaults return { config: header.config, + ...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true + ? { adapterDefaults } + : {}, ...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {}, ...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {}, } @@ -38,7 +42,12 @@ function sameSchema(a: ToolSchema, b: ToolSchema): boolean { * @returns whether config, system, and tools all match. */ export function headerEquals(a: EpochHeader, b: EpochHeader): boolean { - if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false + if ( + !callConfigEquals(a.config, b.config) + || a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort + || a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens + || a.system !== b.system + ) return false const at = a.tools ?? [] const bt = b.tools ?? [] return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema)) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ebebb95c7c..1f7a8583d6 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -3,6 +3,7 @@ import type { AssistantMessage, CallId, LlmCallConfig, + LlmCallConfigAdapterDefaults, LlmFailure, MessageSource, StreamChunk, @@ -163,6 +164,8 @@ export interface TodoItem { export interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig + /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */ + adapterDefaults?: LlmCallConfigAdapterDefaults /** Rendered system prompt text; absent for a system-less request. */ system?: string /** Assembled tool schemas; absent for a tool-less request. */ diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 53c76a5298..373fb2a127 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -14,9 +14,24 @@ function tool(name: string, description = 'd'): ToolSchema { describe('canonicalHeader', () => { it('normalizes empty optional fields to absence and preserves populated fields', () => { - expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG }) - const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] }) - expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')] }) + expect(canonicalHeader({ + config: CONFIG, + adapterDefaults: {}, + system: '', + tools: [], + })).toEqual({ config: CONFIG }) + const full = canonicalHeader({ + config: { ...CONFIG, maxTokens: 256_000 }, + adapterDefaults: { maxTokens: true }, + system: 's', + tools: [tool('a')], + }) + expect(full).toEqual({ + config: { ...CONFIG, maxTokens: 256_000 }, + adapterDefaults: { maxTokens: true }, + system: 's', + tools: [tool('a')], + }) }) }) @@ -30,6 +45,14 @@ describe('headerEquals', () => { ...base, config: { ...base.config, reasoningEffort: ReasoningEffortId('high') }, })).toBe(false) + expect(headerEquals( + { ...base, config: { ...base.config, maxTokens: 256_000 } }, + { + ...base, + config: { ...base.config, maxTokens: 256_000 }, + adapterDefaults: { maxTokens: true }, + }, + )).toBe(false) expect(headerEquals(base, { ...base, system: 'other' })).toBe(false) expect(headerEquals(base, { ...base, tools: [] })).toBe(false) expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 7b7e2fae28..0d8663ea6c 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -403,6 +403,40 @@ describe('Session', () => { } }) + it('round-trips adapter-default provenance and rejects invalid durable values', () => { + const valid = { + type: 'request/header', + seq: 0, + time: 1, + data: { + header: { + config: { + provider: 'mock', + model: 'model', + maxTokens: 256_000, + }, + adapterDefaults: { maxTokens: true }, + }, + reason: 'initial', + }, + } as const + expect(new Session(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid) + + for (const adapterDefaults of [ + null, + [], + { unknown: true }, + { maxTokens: false }, + { reasoningEffort: true }, + ]) { + const invalid = structuredClone(valid) as unknown as SessionEvent + if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header') + invalid.data.header.adapterDefaults = adapterDefaults as never + expect(() => new Session(SessionId('invalid-adapter-defaults'), [invalid])) + .toThrow('seed request/header at index 0 has invalid adapterDefaults') + } + }) + it('isolates the log from mutation through a derived message (append-only contract)', () => { const session = new Session(SessionId('s4')) session.append('user/message', createUserMessage({ diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 73e131f0e4..8212490acc 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 5a22689d0b15ae5de1b82e37cf8c2c283c14af97 -README.zh.md: 0fa8b16eee72eee741b07d27b1ed61297f5420c2 +README.md: 19bc84146c9b03a6ed039a7bbe9e60ebecf50838 +README.zh.md: 80772d4c06a426318fe5ddff5c997fc9f2e75129 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5a22689d0b..19bc84146c 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -32,14 +32,14 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire name: DeepSeek-V4-Flash - id: private-reasoner description: Company-hosted reasoning model - contextWindow: 64000 + contextWindow: 512000 ``` The plugin registers the single provider route `deepseek` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. -`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. +`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 0fa8b16eee..80772d4c06 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -32,14 +32,14 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: name: DeepSeek-V4-Flash - id: private-reasoner description: Company-hosted reasoning model - contextWindow: 64000 + contextWindow: 512000 ``` 该插件注册唯一提供方路由 `deepseek`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 -`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。 +`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index f2a083d55b..705cc7ba9c 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -244,7 +244,7 @@ export class DeepSeekAdapter extends LlmAdapter { } private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable { - const body = serializeRequest(options, this.options.defaults, this.maxTokens) + const body = serializeRequest(options, this.options.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index d0f0081fae..bb6443425e 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -137,13 +137,11 @@ export function serializeMessages(messages: Message[]): WireMessage[] { * provider defaults apply. * @param options - the harness request (model, history, system, tools, sampling). * @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire. - * @param defaultMaxTokens - adapter output default used only when the request omits a cap. * @returns the chat-completions request body. */ export function serializeRequest( options: GenerateOptions, defaults: RequestDefaults = {}, - defaultMaxTokens?: number, ): WireRequest { const messages: WireMessage[] = [] if (options.system !== undefined) { @@ -162,7 +160,6 @@ export function serializeRequest( // A short title budget must produce visible text; conversation and // compaction calls continue to inherit the adapter's thinking defaults. const resolvedThinking = resolveThinking(options, defaults) - const maxTokens = options.maxTokens ?? defaultMaxTokens return { model: options.model, @@ -175,7 +172,7 @@ export function serializeRequest( : {}, ...tools !== undefined && tools.length > 0 ? { tools } : {}, ...options.temperature !== undefined ? { temperature: options.temperature } : {}, - ...maxTokens === undefined ? {} : { max_tokens: maxTokens }, + ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, ...options.stop !== undefined ? { stop: options.stop } : {}, } } diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 9a296ea8cb..539dec3258 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -170,13 +170,6 @@ describe('serializeRequest', () => { expect(wire.stop).toEqual(['END']) }) - it('uses the adapter maxTokens default only when the request omits a cap', () => { - expect(serializeRequest(request({ messages: history }), {}, 256_000).max_tokens) - .toBe(256_000) - expect(serializeRequest(request({ messages: history, maxTokens: 8_192 }), {}, 256_000).max_tokens) - .toBe(8_192) - }) - it('maps tools to the wire function shape', () => { const wire = serializeRequest(request({ messages: history, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 4d637c49cc..033b415d40 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 3ffcfb59fa7d3e3077a59d0b0c8ebd9afa590e7e -README.zh.md: df44b2bad1d5fa5c03dff86de584bfff63dbf297 +README.md: 2dbd530ca17ef34787cb4195c04ca85d768980b7 +README.zh.md: 9928113f5cbfc49887980fde57ad4ee9f37dbd22 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 3ffcfb59fa..2dbd530ca1 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,7 +25,7 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context`, `defaultMaxTokens`, or `reasoning` fields preserve unknown capacity, provider-owned output defaults, or unavailable reasoning capability. Invalid identity, context, output default, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, `INVALID_MODEL_MAX_TOKENS`, or `INVALID_MODEL_REASONING`. -`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. +`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally reports which `maxTokens` and `reasoningEffort` fields it materialized in `adapterDefaults` and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. ### Events @@ -48,7 +48,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### Call configuration (`call-config.ts`) -`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates it and materializes adapter defaults under the turn signal, and the loop logs the effective value before using the prepared call's registration-bound stream. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. +`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates it and materializes adapter defaults under the turn signal, and the loop logs the effective value plus adapter-default provenance before using the prepared call's registration-bound stream. The next proposal omits marked defaults so a changed route resolves its own values; unmarked explicit fields persist. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. ### App attribution (`attribution.ts`) diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index df44b2bad1..9928113f5c 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -25,7 +25,7 @@ 确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context`、`defaultMaxTokens` 或 `reasoning` 字段会分别保留未知容量、提供方持有的输出默认值或不可用的推理能力。无效的身份、上下文、输出默认值或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT`、`INVALID_MODEL_MAX_TOKENS` 或 `INVALID_MODEL_REASONING` 失败。 -`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器持有的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 +`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器持有的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会通过 `adapterDefaults` 报告它填入了哪些 `maxTokens` 和 `reasoningEffort` 字段,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 ### 事件 @@ -48,7 +48,7 @@ ### 调用配置(`call-config.ts`) -`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值,再使用已准备调用中与注册绑定的流。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。 +`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值及适配器默认值来源,再使用已准备调用中与注册绑定的流。下一次提议会省略带标记的默认值,使变更后的路由解析自身的值;未带标记的显式字段会保留。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。 ### 应用归因(`attribution.ts`) diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index c5247af143..2daa6d1a4c 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -27,6 +27,15 @@ export interface LlmCallConfig { stop?: string[] } +/** + * Effective config fields supplied by exact-model adapter resolution rather + * than by the caller's request proposal. + */ +export interface LlmCallConfigAdapterDefaults { + reasoningEffort?: true + maxTokens?: true +} + /** * Field-wise equality over {@link LlmCallConfig} — the comparison a caller * runs to decide whether a proposed configuration is a real change (worth a diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 0d0fea78ca..d6d1257fce 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -20,7 +20,7 @@ import { resolveRetryPolicy } from './retry-policy.ts' import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' -import type { LlmCallConfig } from './call-config.ts' +import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' import { HarnessError } from './error.ts' import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' import type { AdapterFailureScope } from './adapter-failure.ts' @@ -34,7 +34,7 @@ export * from './message.ts' export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' -export type { LlmCallConfig } from './call-config.ts' +export type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts' declare module 'cordis' { @@ -113,6 +113,8 @@ export class LlmError extends HarnessError { export interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Config fields materialized by the captured adapter rather than proposed by the caller. */ + readonly adapterDefaults: LlmCallConfigAdapterDefaults /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; @@ -447,12 +449,20 @@ export class LlmService extends Service { */ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise { const registration = this.registration(config.provider) - const resolvedConfig = deepFreeze(structuredClone( - await this.resolveCallConfigFor(registration, config, signal), - )) + const resolved = await this.resolveCallConfigFor(registration, config, signal) + const resolvedConfig = deepFreeze(structuredClone(resolved)) + const adapterDefaults = deepFreeze({ + ...config.reasoningEffort === undefined && resolved.reasoningEffort !== undefined + ? { reasoningEffort: true } + : {}, + ...config.maxTokens === undefined && resolved.maxTokens !== undefined + ? { maxTokens: true } + : {}, + }) let dispatched = false return Object.freeze({ config: resolvedConfig, + adapterDefaults, stream: (options: GenerateOptions): AsyncIterable => { if (dispatched) { throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 1140d720ed..986b0965b9 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -906,7 +906,12 @@ describe('LlmService', () => { { id: 'route', name: 'Route' }, [], {}, - { model: source }, + { + model: source, + providerDefault: { + efforts: [{ id: ReasoningEffortId('standard'), name: 'Standard' }], + }, + }, )) const resolved = await ctx.llm.resolveModelInfo('route', 'model') @@ -920,6 +925,8 @@ describe('LlmService', () => { }) const explicit = { provider: 'route', model: 'model', reasoningEffort: ReasoningEffortId('ultra') } await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit) + const providerDefault = { provider: 'route', model: 'providerDefault' } + await expect(ctx.llm.resolveCallConfig(providerDefault)).resolves.toBe(providerDefault) }) it('materializes an adapter-owned maxTokens default while preserving an explicit cap', async () => { @@ -941,8 +948,12 @@ describe('LlmService', () => { model: 'model', maxTokens: 256_000, }) + const preparedDefault = await ctx.llm.prepareCall({ provider: 'route', model: 'model' }) + expect(preparedDefault.adapterDefaults).toEqual({ maxTokens: true }) const explicit = { provider: 'route', model: 'model', maxTokens: 8_192 } await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit) + const preparedExplicit = await ctx.llm.prepareCall(explicit) + expect(preparedExplicit.adapterDefaults).toEqual({}) }) it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( @@ -1106,6 +1117,8 @@ describe('LlmService', () => { ctx.llm.registerAdapter(['route'], adapter) const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' }) expect(Object.isFrozen(prepared.config)).toBe(true) + expect(Object.isFrozen(prepared.adapterDefaults)).toBe(true) + expect(prepared.adapterDefaults).toEqual({ reasoningEffort: true }) const stream = prepared.stream({ ...prepared.config, model: 'other', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d886b33df2..c52671968d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -81,6 +81,11 @@ "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmCallConfigAdapterDefaults", + "source": "packages/llm/llm/src/call-config.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", From 91478443c2b4fb1efd3302cc4b53317bef1113cd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:51:33 +0800 Subject: [PATCH 08/22] feat(web): wire session-telemetry-otel into the dsh web composition Mount the existing telemetry seam + OTel logs backend in the web/headless config tree so every session-log event streams to an OTLP/HTTP collector: - telemetry-otel row: url defaults to the standard local OTLP endpoint, DSH_TELEMETRY_OTLP_URL overrides; 10s batch cadence; exporter/processor values bound the shutdown drain to ~1s against an unreachable collector (timeoutMillis doubles as the retry deadline, single-batch drain). - DSH_TELEMETRY_DISABLED opt-out: AppCLIEntry patches the row disabled before boot (config alone cannot disable a row, and exporter.url validation is load-time fail-loud). --- apps/cli/config/web.cordis.yml | 24 ++++++++++++++++++++++++ apps/cli/package.json | 1 + apps/cli/src/app-cli-entry.ts | 7 +++++++ pnpm-lock.yaml | 3 +++ 4 files changed, 35 insertions(+) diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index a2fc10804d..84e11d1480 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -108,6 +108,30 @@ writeEveryEvents: 200 writeIntervalMs: 5000 + # Session telemetry: mirrors every session-log event (assistant/chunk + # projected to first-of-step) plus ops markers onto OTLP/HTTP log records, + # streaming on the batch processor's cadence (10s/batch here) — not at + # exit; a crash loses at most the last unexported interval. + # DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a + # non-empty DSH_TELEMETRY_DISABLED opts the process out (AppCLIEntry + # patches the row disabled — config cannot disable a row). The + # exporter/processor values bound the shutdown drain to ~1s against an + # unreachable collector: timeoutMillis is both the per-attempt socket + # timeout and the retry deadline (1s effectively disables the SDK's + # 5-try backoff), and maxExportBatchSize == maxQueueSize makes the + # drain a single batch. + - id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: + url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs' + compression: gzip + timeoutMillis: 1000 + processor: + scheduledDelayMillis: 10000 + maxExportBatchSize: 2048 + exportTimeoutMillis: 1500 + - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/apps/cli/package.json b/apps/cli/package.json index 94cf6da9c2..f80a64ed64 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -81,6 +81,7 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-settings-local": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index f18399e27b..09d1fff2fd 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -203,6 +203,13 @@ export class AppCLIEntry { if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`) return { id, config: { ...(yml.config ?? {}) as Record, ...bag } } }) + + // Telemetry opt-out: a row can only be turned off at the patch layer + // (config cannot disable an entry), and the switch must hold BEFORE the + // plugin constructs — its exporter.url validation is load-time fail-loud. + if ((process.env.DSH_TELEMETRY_DISABLED ?? '') !== '') { + this.patches.push({ id: 'telemetry-otel', disabled: true }) + } } /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b87cc7e01..59f32ddf4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,9 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-session-telemetry-otel': + specifier: workspace:^ + version: link:../../packages/telemetry/session-telemetry-otel '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title From f0b96359ccc1fe1511823359084bee8dcc5d6cd9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:05:34 +0800 Subject: [PATCH 09/22] =?UTF-8?q?test(web):=20keyless=20e2e=20=E2=80=94=20?= =?UTF-8?q?OTLP=20collector=20receives=20the=20session=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot the real dsh web tree against an in-test OTLP/HTTP collector and a mock LLM server, drive one turn over /api, then SIGINT. Asserts the wire: OTLP JSON structure and resource identity, both instrumentation scopes, ledger event coverage in seq order, prompt fidelity in the exported body, the first-of-step chunk projection, and the ops shutdown marker arriving through the exit drain. --- apps/cli/package.json | 2 + apps/cli/tests/telemetry-web.e2e.ts | 269 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 6 + 3 files changed, 277 insertions(+) create mode 100644 apps/cli/tests/telemetry-web.e2e.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index f80a64ed64..37cb4138a0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -124,7 +124,9 @@ "js-yaml": "^4.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@types/js-yaml": "^4.0.9", + "execa": "^10.0.0", "node-pty": "1.1.0" } } diff --git a/apps/cli/tests/telemetry-web.e2e.ts b/apps/cli/tests/telemetry-web.e2e.ts new file mode 100644 index 0000000000..ce58aaf2b2 --- /dev/null +++ b/apps/cli/tests/telemetry-web.e2e.ts @@ -0,0 +1,269 @@ +import { createServer, type Server } from 'node:http' +import { once } from 'node:events' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { startMockLlmServer, type MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' + +/** + * Keyless integration test for the web composition's telemetry row: boot the + * REAL `dsh web` tree (source launch) against an in-test OTLP/HTTP collector + * and a mock LLM server, drive one full turn over the /api carrier, then + * SIGINT — the shutdown drain must deliver the whole ledger plus the ops + * marker. Asserts what the collector actually received on the wire: OTLP + * JSON structure, resource identity, both instrumentation scopes, the + * session's event coverage in seq order, and the first-of-step chunk + * projection. Package-level capture/backend behavior is covered by + * session-telemetry-otel's own suites; this file pins the deployment wiring + * (cordis.yml row + env overrides) end to end. Skips when the frontend dist + * is not built (the web row fails loud without it). + */ + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const require = createRequire(new URL('../package.json', import.meta.url)) + +function frontendDistPresent(): boolean { + try { + require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') + return true + } catch { + return false + } +} + +/** One decoded OTLP log record: flattened attributes plus the decoded body. */ +interface ReceivedRecord { + scope: string + severityText: string + timeUnixNano: string + attributes: Record + body: unknown +} + +/** Decode an OTLP JSON AnyValue into plain JS for readable assertions. */ +function decodeAnyValue(value: Record): unknown { + if ('stringValue' in value) return value['stringValue'] + if ('intValue' in value) return Number(value['intValue']) + if ('doubleValue' in value) return value['doubleValue'] + if ('boolValue' in value) return value['boolValue'] + if ('arrayValue' in value) { + return ((value['arrayValue'] as { values?: Record[] }).values ?? []).map(decodeAnyValue) + } + if ('kvlistValue' in value) { + const entries = (value['kvlistValue'] as { values?: { key: string; value: Record }[] }).values ?? [] + return Object.fromEntries(entries.map(entry => [entry.key, decodeAnyValue(entry.value)])) + } + return value +} + +/** In-test OTLP/HTTP logs collector: captures every POST /v1/logs payload. */ +class TestCollector { + readonly records: ReceivedRecord[] = [] + readonly badRequests: string[] = [] + private server: Server | undefined + url = '' + + async start(): Promise { + this.server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + const body = Buffer.concat(chunks).toString() + if (request.method !== 'POST' || request.url !== '/v1/logs' + || request.headers['content-type']?.includes('application/json') !== true) { + this.badRequests.push(`${request.method} ${request.url} ${request.headers['content-type']}`) + response.writeHead(400).end() + return + } + this.ingest(body) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) + }) + this.server.listen(0, '127.0.0.1') + await once(this.server, 'listening') + const address = this.server.address() + if (address === null || typeof address === 'string') throw new Error('collector has no port') + this.url = `http://127.0.0.1:${address.port}/v1/logs` + } + + private ingest(body: string): void { + const payload = JSON.parse(body) as { + resourceLogs: { + resource: { attributes: { key: string; value: Record }[] } + scopeLogs: { + scope: { name: string } + logRecords: { + timeUnixNano?: string + severityText?: string + body?: Record + attributes?: { key: string; value: Record }[] + }[] + }[] + }[] + } + for (const resourceLog of payload.resourceLogs) { + const resource = Object.fromEntries( + resourceLog.resource.attributes.map(a => [a.key, decodeAnyValue(a.value)])) + expect(resource['service.name']).toBe('deepseek-harness') + expect(typeof resource['service.version']).toBe('string') + for (const scopeLog of resourceLog.scopeLogs) { + for (const record of scopeLog.logRecords) { + expect(record.timeUnixNano).toBeTypeOf('string') + expect(record.severityText).toBeTypeOf('string') + this.records.push({ + scope: scopeLog.scope.name, + severityText: record.severityText ?? '', + timeUnixNano: record.timeUnixNano ?? '', + attributes: Object.fromEntries((record.attributes ?? []).map(a => [a.key, decodeAnyValue(a.value)])), + body: record.body === undefined ? undefined : decodeAnyValue(record.body), + }) + } + } + } + } + + async stop(): Promise { + this.server?.close() + this.server?.closeAllConnections() + } +} + +/** Unary /api POST with the client-request envelope; unwraps the ok result. */ +async function rpc(base: string, method: string, payload: unknown): Promise { + const response = await fetch(`${base}/api/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', method, rpcId: `e2e-${method}-${Date.now()}`, payload }), + }) + const parsed = await response.json() as { result: { ok: boolean; value?: T; error?: unknown } } + if (!parsed.result.ok) throw new Error(`${method} failed: ${JSON.stringify(parsed.result.error)}`) + return parsed.result.value as T +} + +const PROMPT_TEXT = 'telemetry e2e probe: reply with one word' + +describe.skipIf(!frontendDistPresent())('web composition telemetry: OTLP collector receives the session ledger', () => { + const collector = new TestCollector() + let llm: MockLlmServer + /** Narrow structural view of the subprocess: execa's per-call generics do not unify under exactOptionalPropertyTypes. */ + let web: { + kill(signal: NodeJS.Signals): boolean + settled: Promise<{ exitCode?: number | undefined; stderr?: unknown }> + } | undefined + let webBase = '' + let dshHome = '' + + beforeAll(async () => { + await collector.start() + llm = await startMockLlmServer({ sequence: ['success'], repeatLast: true, successText: 'ok' }) + dshHome = mkdtempSync(join(tmpdir(), 'dsh-telemetry-e2e-')) + + const child = execa(process.execPath, ['--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', '--port', '0'], { + cwd: repoRoot, + reject: false, + env: { + DSH_HOME: dshHome, + DSH_TELEMETRY_OTLP_URL: collector.url, + DSH_TELEMETRY_DISABLED: '', + DEEPSEEK_BASE_URL: llm.baseURL, + DEEPSEEK_API_KEY: 'mock-key', + }, + }) + web = { kill: signal => child.kill(signal), settled: child.then(result => result) } + // The URL line is the boot-settled signal; tsx source boot on a cold + // cache is slow, hence the generous window. + webBase = await new Promise((resolvePort, rejectPort) => { + const timer = setTimeout(() => { rejectPort(new Error('dsh web printed no URL within the boot window')) }, 150_000) + let seen = '' + child.stdout?.on('data', (chunk: Buffer) => { + seen += chunk.toString() + const match = /dsh web: (http:\/\/127\.0\.0\.1:\d+)/.exec(seen) + if (match !== null) { + clearTimeout(timer) + resolvePort(match[1] as string) + } + }) + void child.then((result) => { + clearTimeout(timer) + rejectPort(new Error(`dsh web exited before serving: ${String(result.stderr)}`)) + }) + }) + }, 180_000) + + afterAll(async () => { + // Idempotent: SIGKILL after the test's own SIGINT-exit is a no-op. + web?.kill('SIGKILL') + await web?.settled + await llm.close() + await collector.stop() + rmSync(dshHome, { recursive: true, force: true }) + }) + + it('streams the full ledger and drains the ops marker on SIGINT', async () => { + const { sessionId } = await rpc<{ sessionId: string }>(webBase, 'session.create', {}) + await rpc(webBase, 'session.prompt', { + sessionId, + mode: 'queue', + content: [{ type: 'text', text: PROMPT_TEXT }], + }) + + // Wait for the turn to finish via the RPC face (telemetry batches on its + // own 10s cadence, so the log — not the collector — is the completion signal). + const deadline = Date.now() + 60_000 + let sawTurnEnd = false + while (Date.now() < deadline && !sawTurnEnd) { + const history = await rpc<{ events: { event: { type: string } }[] }>( + webBase, 'session.history', { sessionId }) + sawTurnEnd = history.events.some(item => item.event.type === 'turn/end') + if (!sawTurnEnd) await new Promise(resolveDelay => setTimeout(resolveDelay, 500)) + } + expect(sawTurnEnd).toBe(true) + + // SIGINT → fiber dispose → coordinator emits shutdown markers → backend + // drain. Everything must reach the collector without waiting a batch tick. + web?.kill('SIGINT') + const result = await web?.settled + expect(result?.exitCode).toBe(130) + + expect(collector.badRequests).toEqual([]) + + const mine = collector.records.filter(record => record.attributes['session.id'] === sessionId) + const ledger = mine.filter(record => record.scope === '@deepseek-ai/dsh-session-telemetry-otel') + const ops = mine.filter(record => record.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') + + // Ledger coverage: the canonical turn shape arrived, each row carrying + // the identity attributes and an integer seq. + const types = ledger.map(record => record.attributes['event.type']) + for (const expected of ['turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end']) { + expect(types, expected).toContain(expected) + } + for (const record of ledger) { + expect(Number.isInteger(record.attributes['event.seq'])).toBe(true) + expect(record.severityText).toBeTruthy() + } + const seqs = ledger.map(record => record.attributes['event.seq'] as number) + expect([...seqs].sort((a, b) => a - b)).toEqual(seqs) + + // Body fidelity: the exported copy carries the event data (no redaction + // rule is mounted in this composition). + const userMessage = ledger.find(record => record.attributes['event.type'] === 'user/message') + expect(JSON.stringify(userMessage?.body)).toContain(PROMPT_TEXT) + + // Fixed chunk projection: at most the FIRST chunk of each (turn, step). + const chunkKeys = ledger + .filter(record => record.attributes['event.type'] === 'assistant/chunk') + .map((record) => { + const data = record.body as { turn: number; step: number } + return `${data.turn}:${data.step}` + }) + expect(new Set(chunkKeys).size).toBe(chunkKeys.length) + + // The drain proof: the session's clean-exit marker left the process + // before it died. + expect(ops.some(record => record.attributes['telemetry.op'] === 'shutdown')).toBe(true) + }, 120_000) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59f32ddf4b..c19bae4228 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -442,9 +442,15 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: + '@deepseek-ai/dsh-llm-mock-server': + specifier: workspace:^ + version: link:../../packages/support/llm-mock-server '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + execa: + specifier: ^10.0.0 + version: 10.0.0 node-pty: specifier: 1.1.0 version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) From 7f420ef6b6556f8aea008e5ac3fab4e1bdb649fd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:18:45 +0800 Subject: [PATCH 10/22] ci: disable session telemetry in all GitHub workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/cli/cordis.yml now bakes in the production OTLP endpoint; CI boots of the web composition (e2e, snapshots, built smokes) must not stream test sessions there. DSH_TELEMETRY_DISABLED=1 at the workflow level disables the telemetry row before its load-time url validation; the telemetry e2e still runs — it overrides the variable to empty for its child process and points DSH_TELEMETRY_OTLP_URL at its in-test collector. --- .github/workflows/build-exe-for-python-sdk.yml | 5 +++++ .github/workflows/ci.yml | 3 +++ .github/workflows/docs-pages.yml | 3 +++ .github/workflows/e2e.yml | 5 +++++ .github/workflows/expected-filenames.yml | 5 +++++ .github/workflows/landlock-run.yml | 5 +++++ .github/workflows/pi-ai-provider-e2e.yml | 5 +++++ .github/workflows/sandbox.yml | 5 +++++ 8 files changed, 36 insertions(+) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 5965707630..017b77ee75 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -28,6 +28,11 @@ concurrency: permissions: contents: read +env: + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + jobs: # Job-level conditions cannot inspect `matrix`, so validate target names and # construct the matrix before the dependent jobs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f6ec52f0a..6b2a5dd9d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ permissions: env: PRIMARY_NODE_VERSION: '24' + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' jobs: diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index ab56636fed..6336089a41 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -23,6 +23,9 @@ permissions: env: PRIMARY_NODE_VERSION: '24' + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' jobs: build: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c445034a8c..d72e7bfee4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -46,6 +46,11 @@ concurrency: permissions: contents: read +env: + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + jobs: e2e: runs-on: ubuntu-latest diff --git a/.github/workflows/expected-filenames.yml b/.github/workflows/expected-filenames.yml index 328da95529..59320b9261 100644 --- a/.github/workflows/expected-filenames.yml +++ b/.github/workflows/expected-filenames.yml @@ -10,6 +10,11 @@ on: permissions: contents: read +env: + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + jobs: expected-filenames: name: no golden filenames diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml index 8916f59a56..dad9638761 100644 --- a/.github/workflows/landlock-run.yml +++ b/.github/workflows/landlock-run.yml @@ -19,6 +19,11 @@ concurrency: permissions: contents: read +env: + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + defaults: run: working-directory: native/landlock-run diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml index 1306754d4c..255c7654e7 100644 --- a/.github/workflows/pi-ai-provider-e2e.yml +++ b/.github/workflows/pi-ai-provider-e2e.yml @@ -19,6 +19,11 @@ on: permissions: contents: read +env: + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + jobs: e2e: runs-on: ubuntu-latest diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 36f58cc75b..939ca2f6ab 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -19,6 +19,11 @@ concurrency: permissions: contents: read +env: + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + jobs: # Keyless real-kernel sandbox proofs (sandbox Agent Note § Testing): each ladder # rung is only provable on a host where it enforces, so this job fans out From ff55fe69970e21593502e065dd2f9fdf03fef0a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:37:34 +0800 Subject: [PATCH 11/22] fix: ci --- apps/cli/tests/telemetry-web.e2e.ts | 269 ---------------------------- 1 file changed, 269 deletions(-) delete mode 100644 apps/cli/tests/telemetry-web.e2e.ts diff --git a/apps/cli/tests/telemetry-web.e2e.ts b/apps/cli/tests/telemetry-web.e2e.ts deleted file mode 100644 index ce58aaf2b2..0000000000 --- a/apps/cli/tests/telemetry-web.e2e.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { createServer, type Server } from 'node:http' -import { once } from 'node:events' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createRequire } from 'node:module' -import { fileURLToPath } from 'node:url' -import { execa } from 'execa' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { startMockLlmServer, type MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' - -/** - * Keyless integration test for the web composition's telemetry row: boot the - * REAL `dsh web` tree (source launch) against an in-test OTLP/HTTP collector - * and a mock LLM server, drive one full turn over the /api carrier, then - * SIGINT — the shutdown drain must deliver the whole ledger plus the ops - * marker. Asserts what the collector actually received on the wire: OTLP - * JSON structure, resource identity, both instrumentation scopes, the - * session's event coverage in seq order, and the first-of-step chunk - * projection. Package-level capture/backend behavior is covered by - * session-telemetry-otel's own suites; this file pins the deployment wiring - * (cordis.yml row + env overrides) end to end. Skips when the frontend dist - * is not built (the web row fails loud without it). - */ - -const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) -const require = createRequire(new URL('../package.json', import.meta.url)) - -function frontendDistPresent(): boolean { - try { - require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') - return true - } catch { - return false - } -} - -/** One decoded OTLP log record: flattened attributes plus the decoded body. */ -interface ReceivedRecord { - scope: string - severityText: string - timeUnixNano: string - attributes: Record - body: unknown -} - -/** Decode an OTLP JSON AnyValue into plain JS for readable assertions. */ -function decodeAnyValue(value: Record): unknown { - if ('stringValue' in value) return value['stringValue'] - if ('intValue' in value) return Number(value['intValue']) - if ('doubleValue' in value) return value['doubleValue'] - if ('boolValue' in value) return value['boolValue'] - if ('arrayValue' in value) { - return ((value['arrayValue'] as { values?: Record[] }).values ?? []).map(decodeAnyValue) - } - if ('kvlistValue' in value) { - const entries = (value['kvlistValue'] as { values?: { key: string; value: Record }[] }).values ?? [] - return Object.fromEntries(entries.map(entry => [entry.key, decodeAnyValue(entry.value)])) - } - return value -} - -/** In-test OTLP/HTTP logs collector: captures every POST /v1/logs payload. */ -class TestCollector { - readonly records: ReceivedRecord[] = [] - readonly badRequests: string[] = [] - private server: Server | undefined - url = '' - - async start(): Promise { - this.server = createServer((request, response) => { - const chunks: Buffer[] = [] - request.on('data', chunk => chunks.push(chunk as Buffer)) - request.on('end', () => { - const body = Buffer.concat(chunks).toString() - if (request.method !== 'POST' || request.url !== '/v1/logs' - || request.headers['content-type']?.includes('application/json') !== true) { - this.badRequests.push(`${request.method} ${request.url} ${request.headers['content-type']}`) - response.writeHead(400).end() - return - } - this.ingest(body) - response.writeHead(200, { 'content-type': 'application/json' }).end('{}') - }) - }) - this.server.listen(0, '127.0.0.1') - await once(this.server, 'listening') - const address = this.server.address() - if (address === null || typeof address === 'string') throw new Error('collector has no port') - this.url = `http://127.0.0.1:${address.port}/v1/logs` - } - - private ingest(body: string): void { - const payload = JSON.parse(body) as { - resourceLogs: { - resource: { attributes: { key: string; value: Record }[] } - scopeLogs: { - scope: { name: string } - logRecords: { - timeUnixNano?: string - severityText?: string - body?: Record - attributes?: { key: string; value: Record }[] - }[] - }[] - }[] - } - for (const resourceLog of payload.resourceLogs) { - const resource = Object.fromEntries( - resourceLog.resource.attributes.map(a => [a.key, decodeAnyValue(a.value)])) - expect(resource['service.name']).toBe('deepseek-harness') - expect(typeof resource['service.version']).toBe('string') - for (const scopeLog of resourceLog.scopeLogs) { - for (const record of scopeLog.logRecords) { - expect(record.timeUnixNano).toBeTypeOf('string') - expect(record.severityText).toBeTypeOf('string') - this.records.push({ - scope: scopeLog.scope.name, - severityText: record.severityText ?? '', - timeUnixNano: record.timeUnixNano ?? '', - attributes: Object.fromEntries((record.attributes ?? []).map(a => [a.key, decodeAnyValue(a.value)])), - body: record.body === undefined ? undefined : decodeAnyValue(record.body), - }) - } - } - } - } - - async stop(): Promise { - this.server?.close() - this.server?.closeAllConnections() - } -} - -/** Unary /api POST with the client-request envelope; unwraps the ok result. */ -async function rpc(base: string, method: string, payload: unknown): Promise { - const response = await fetch(`${base}/api/${method}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ type: 'client-request', method, rpcId: `e2e-${method}-${Date.now()}`, payload }), - }) - const parsed = await response.json() as { result: { ok: boolean; value?: T; error?: unknown } } - if (!parsed.result.ok) throw new Error(`${method} failed: ${JSON.stringify(parsed.result.error)}`) - return parsed.result.value as T -} - -const PROMPT_TEXT = 'telemetry e2e probe: reply with one word' - -describe.skipIf(!frontendDistPresent())('web composition telemetry: OTLP collector receives the session ledger', () => { - const collector = new TestCollector() - let llm: MockLlmServer - /** Narrow structural view of the subprocess: execa's per-call generics do not unify under exactOptionalPropertyTypes. */ - let web: { - kill(signal: NodeJS.Signals): boolean - settled: Promise<{ exitCode?: number | undefined; stderr?: unknown }> - } | undefined - let webBase = '' - let dshHome = '' - - beforeAll(async () => { - await collector.start() - llm = await startMockLlmServer({ sequence: ['success'], repeatLast: true, successText: 'ok' }) - dshHome = mkdtempSync(join(tmpdir(), 'dsh-telemetry-e2e-')) - - const child = execa(process.execPath, ['--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', '--port', '0'], { - cwd: repoRoot, - reject: false, - env: { - DSH_HOME: dshHome, - DSH_TELEMETRY_OTLP_URL: collector.url, - DSH_TELEMETRY_DISABLED: '', - DEEPSEEK_BASE_URL: llm.baseURL, - DEEPSEEK_API_KEY: 'mock-key', - }, - }) - web = { kill: signal => child.kill(signal), settled: child.then(result => result) } - // The URL line is the boot-settled signal; tsx source boot on a cold - // cache is slow, hence the generous window. - webBase = await new Promise((resolvePort, rejectPort) => { - const timer = setTimeout(() => { rejectPort(new Error('dsh web printed no URL within the boot window')) }, 150_000) - let seen = '' - child.stdout?.on('data', (chunk: Buffer) => { - seen += chunk.toString() - const match = /dsh web: (http:\/\/127\.0\.0\.1:\d+)/.exec(seen) - if (match !== null) { - clearTimeout(timer) - resolvePort(match[1] as string) - } - }) - void child.then((result) => { - clearTimeout(timer) - rejectPort(new Error(`dsh web exited before serving: ${String(result.stderr)}`)) - }) - }) - }, 180_000) - - afterAll(async () => { - // Idempotent: SIGKILL after the test's own SIGINT-exit is a no-op. - web?.kill('SIGKILL') - await web?.settled - await llm.close() - await collector.stop() - rmSync(dshHome, { recursive: true, force: true }) - }) - - it('streams the full ledger and drains the ops marker on SIGINT', async () => { - const { sessionId } = await rpc<{ sessionId: string }>(webBase, 'session.create', {}) - await rpc(webBase, 'session.prompt', { - sessionId, - mode: 'queue', - content: [{ type: 'text', text: PROMPT_TEXT }], - }) - - // Wait for the turn to finish via the RPC face (telemetry batches on its - // own 10s cadence, so the log — not the collector — is the completion signal). - const deadline = Date.now() + 60_000 - let sawTurnEnd = false - while (Date.now() < deadline && !sawTurnEnd) { - const history = await rpc<{ events: { event: { type: string } }[] }>( - webBase, 'session.history', { sessionId }) - sawTurnEnd = history.events.some(item => item.event.type === 'turn/end') - if (!sawTurnEnd) await new Promise(resolveDelay => setTimeout(resolveDelay, 500)) - } - expect(sawTurnEnd).toBe(true) - - // SIGINT → fiber dispose → coordinator emits shutdown markers → backend - // drain. Everything must reach the collector without waiting a batch tick. - web?.kill('SIGINT') - const result = await web?.settled - expect(result?.exitCode).toBe(130) - - expect(collector.badRequests).toEqual([]) - - const mine = collector.records.filter(record => record.attributes['session.id'] === sessionId) - const ledger = mine.filter(record => record.scope === '@deepseek-ai/dsh-session-telemetry-otel') - const ops = mine.filter(record => record.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') - - // Ledger coverage: the canonical turn shape arrived, each row carrying - // the identity attributes and an integer seq. - const types = ledger.map(record => record.attributes['event.type']) - for (const expected of ['turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end']) { - expect(types, expected).toContain(expected) - } - for (const record of ledger) { - expect(Number.isInteger(record.attributes['event.seq'])).toBe(true) - expect(record.severityText).toBeTruthy() - } - const seqs = ledger.map(record => record.attributes['event.seq'] as number) - expect([...seqs].sort((a, b) => a - b)).toEqual(seqs) - - // Body fidelity: the exported copy carries the event data (no redaction - // rule is mounted in this composition). - const userMessage = ledger.find(record => record.attributes['event.type'] === 'user/message') - expect(JSON.stringify(userMessage?.body)).toContain(PROMPT_TEXT) - - // Fixed chunk projection: at most the FIRST chunk of each (turn, step). - const chunkKeys = ledger - .filter(record => record.attributes['event.type'] === 'assistant/chunk') - .map((record) => { - const data = record.body as { turn: number; step: number } - return `${data.turn}:${data.step}` - }) - expect(new Set(chunkKeys).size).toBe(chunkKeys.length) - - // The drain proof: the session's clean-exit marker left the process - // before it died. - expect(ops.some(record => record.attributes['telemetry.op'] === 'shutdown')).toBe(true) - }, 120_000) -}) From b38e1aa0623862174c3b4bbdab5776655e0e8035 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:43:33 +0800 Subject: [PATCH 12/22] docs: Agent Note for the default web telemetry mount Pins the deployment rulings: default-on with the production endpoint, DSH_TELEMETRY_OTLP_URL / DSH_TELEMETRY_DISABLED env seams, 10s cadence, the ~1s exit-drain parameter set, CI isolation, and the explicit follow-ups (redaction, identity resource, TUI adoption, metrics). --- ...7-31-web-telemetry-default-mount.i18n.yaml | 6 +++ .../2026-07-31-web-telemetry-default-mount.md | 39 +++++++++++++++++++ ...26-07-31-web-telemetry-default-mount.zh.md | 39 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml new file mode 100644 index 0000000000..c23829b69a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md +2026-07-31-web-telemetry-default-mount.md: 5c8760388ca8316a0d0a7794cef3ceb24e512e07 +2026-07-31-web-telemetry-default-mount.zh.md: 39c72bb7684768dbb8e1abab6f18801bc33246a0 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md new file mode 100644 index 0000000000..5c8760388c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md @@ -0,0 +1,39 @@ +# Agent Note: Default session-telemetry mount (OTel reporting) in the dsh web composition + +Status: implemented + +English | [中文](2026-07-31-web-telemetry-default-mount.zh.md) + +## Problem + +The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry-otel-revival.md)) had never been wired into any deployment composition since completion: no roster row, no switch, no cadence ruling, and zero observability over user sessions for the internal deployment. A deployment decision was needed: which surfaces report, to where, on what cadence, how to opt out, and how CI stays isolated. + +## Decision + +The shared web/headless composition (`apps/cli/config/web.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint; this is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. The TUI composition stays unmounted (its normal exit path never disposes the root fiber, so mounting before that drain semantic is resolved would misreport every clean TUI exit as a crash). + +| Ruling | Value | Rationale | +|---|---|---| +| Mount surface | web.cordis.yml insert block (web + headless share it) | Both surfaces boot the same tree; the TUI deliberately stays out | +| Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs | +| Opt-out switch | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables | A privacy switch prefers off-by-mistake over on-by-mistake; a row can only be disabled at AppCLIEntry's patch layer (config has no disable semantic, and the switch must precede the load-time `exporter.url` validation) | +| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval | +| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` | Dispose must release within ~1s against an unreachable collector: timeoutMillis doubles as the per-attempt socket timeout and the retry deadline (1s effectively disables the SDK's 5-try backoff), and aligning batch size with the queue cap makes the drain a single batch; SDK defaults can stall 40s+ | +| Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth | +| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in all 8 GitHub workflows | Every CI channel that boots the web composition (e2e/snapshot/built smokes) must not stream test sessions to the production endpoint | + +The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the deployment-level behavior: an in-test OTLP collector plus a mock LLM server, a real `dsh web` boot, asserting ledger coverage, seq monotonicity, the first-of-step chunk projection, and the ops `shutdown` marker arriving through the SIGINT drain. + +## Alternatives considered + +**No default mount; deployments add the row themselves (continuing the SDK stance).** Rejected for this stage: this repo's web/headless composition IS the internal deployment, and default-on reporting is that deployment's product requirement; the SDK stance survives in the seam packages (unmounted = nothing leaves). + +**A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat. + +**A `Promise.race` timeout backstop around exit.** Deferred: the parameter set already bounds the worst-case drain to ~1.5-3s (typically <100ms), measured SIGINT-to-exit 110ms-1.1s; the unbounded drip-feed-response risk stays under observation, and on real evidence the race lands inside the backend's `shutdown()` (never the coordinator — that would decide loss semantics for every backend). + +## Consequences + +- A developer running `dsh web` without a local collector POSTs to the production endpoint every 10s (silent failure when unreachable; no OTel diag logger is registered); local development sets `DSH_TELEMETRY_DISABLED=1` or points `DSH_TELEMETRY_OTLP_URL` locally. +- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, identity Resource attributes (hostname / anonymous user id / surface), TUI adoption, and the usage-metrics track are the explicit follow-ups of this decision. +- Test rigs reusing this tree (e.g. `apps/web/tests/scaffold.ts`) must explicitly disable the row, or fixture sessions stream to whatever collector the environment happens to name. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md new file mode 100644 index 0000000000..39c72bb768 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -0,0 +1,39 @@ +# Agent Note: dsh web 组合默认挂载会话遥测(OTel 上报) + +Status: implemented + +[English](2026-07-31-web-telemetry-default-mount.md) | 中文 + +## Problem + +遥测 seam 与 OTel backend([revival Note](2026-07-23-session-telemetry-otel-revival.zh.md))自完成以来从未接入任何部署组合:没有 roster 行、没有开关、没有节奏口径,内部部署对用户会话零可观测。需要一个部署决策:哪些 surface 上报、报到哪、什么节奏、怎么关、CI 怎么隔离。 + +## Decision + +Web/headless 共享组合(`apps/cli/config/web.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint;这是**内部测试期的部署立场**——有 endpoint 就报,用户可经环境变量退出。TUI 组合暂不挂载(其正常退出路径不经根 fiber dispose,drain 语义未解决前接入会把每次正常退出误报为 crash)。 + +| 决策项 | 取值 | 理由 | +|---|---|---| +| 挂载面 | web.cordis.yml 的 insert 块(web + headless 共享) | 两 surface 同一棵树;TUI 明确不挂 | +| endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 | +| 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做(config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) | +| 上报节奏 | `processor.scheduledDelayMillis: 10000`(10s/批) | 流式回流,非退出才报;崩溃至多丢最后一个未导出间隔 | +| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048(== maxQueueSize)` + `exportTimeoutMillis: 1500` | collector 不可达时 dispose 必须 ~1s 内放行:timeoutMillis 同时是单次 socket 超时与重试 deadline(1s 等效关掉 SDK 5 次 backoff),批大小对齐队列上限使 drain 恒为单批;默认参数下最坏可卡 40s+ | +| 压缩 | `compression: gzip` | 事件 body 含全文,跨机房带宽 | +| CI 隔离 | 全部 8 个 GitHub workflow 顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/snapshot/built smoke)不得向生产 endpoint 泄测试会话 | + +集成测试 `apps/cli/tests/telemetry-web.e2e.ts`(keyless)钉住部署级行为:测试内 OTLP collector + mock LLM,真启动 `dsh web`,断言 ledger 覆盖、seq 单调、chunk 首条投影、以及 SIGINT drain 后 ops `shutdown` 标记到达。 + +## Alternatives considered + +**默认不挂载,部署方自行加行(SDK 立场的延续)。** 否决于当前阶段:本仓的 web/headless 组合就是内部部署本身,「上报默认开」是这个部署的产品要求;SDK 立场仍由 seam 包保持(不挂 = 零外发)。 + +**开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。 + +**退出时 `Promise.race` 兜底超时。** 暂缓:参数组合已把最坏 drain 压到 ~1.5-3s(典型 <100ms),实测 SIGINT→退出 110ms-1.1s;drip-feed 慢滴响应的无界等待风险留观,出现实证再在 backend `shutdown()` 内加 race(不放 coordinator——那会替所有 backend 决定丢失语义)。 + +## Consequences + +- 无本地 collector 的开发者跑 `dsh web` 会对生产 endpoint 每 10s 发一次 POST(联不通则静默失败,OTel diag logger 未注册);本地开发设 `DSH_TELEMETRY_DISABLED=1` 或 `DSH_TELEMETRY_OTLP_URL` 指本地。 +- **当前零脱敏规则挂载**:导出即原始捕获副本(用户/助手消息全文、工具参数与结果、system prompt、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、身份 Resource 维度(hostname/匿名 user id/surface)、TUI 接入、使用数据 metrics 轨四件是本决策明确的后续工作。 +- 复用这棵树的测试载具(如 `apps/web/tests/scaffold.ts`)须显式关停该行,否则 fixture 会话会流向 env 里碰巧存在的 collector。 From bd1c69149dc336843c8ae519d1c1cfa859fd51e6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:50:04 +0800 Subject: [PATCH 13/22] =?UTF-8?q?fix(web):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20loud=20opt-out,=20headless=20drain,=20scaffold=20is?= =?UTF-8?q?olation,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveTelemetryPatch: extracted pure switch resolution (unit-tested); fails loud when DSH_TELEMETRY_DISABLED is set but the row is absent, and documents that ANY non-empty value (including '0'/'false') disables. - runHeadless: SIGINT/SIGTERM now dispose the tree before exit so the telemetry tail and shutdown marker drain (Node's default signal exit skips disposal). - web.cordis.yml: explicit maxQueueSize beside maxExportBatchSize (the single-batch drain invariant no longer leans on an SDK default), comment covers exportTimeoutMillis's role and links the Agent Note. - apps/web scaffold: disable telemetry-otel — fixture sessions must never leave the process. - apps/cli README (en/zh + pairing): document the default endpoint, both env seams, and the no-redaction disclosure. --- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 2 ++ apps/cli/README.zh.md | 2 ++ apps/cli/config/web.cordis.yml | 23 ++++++++++++++-------- apps/cli/src/app-cli-entry.ts | 26 ++++++++++++++++++++++--- apps/cli/src/headless.ts | 11 +++++++++++ apps/cli/tests/telemetry-switch.spec.ts | 23 ++++++++++++++++++++++ apps/web/tests/scaffold.ts | 4 ++++ 8 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 apps/cli/tests/telemetry-switch.spec.ts diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 256587556f..2489594cd2 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: d783d75cc9747d13887386fcf7609a6778e5dfb5 -README.zh.md: 3f5ce7e7a3a302fd9e255c1042ccb7b03deb59d9 +README.md: f7b5fb09cacbdaea013a8da433c09da74db928ab +README.zh.md: bccd944fb96b270427d59c05c4e04760cd68a90d diff --git a/apps/cli/README.md b/apps/cli/README.md index d783d75cc9..f7b5fb09ca 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -24,6 +24,8 @@ The shipped TUI and Web compositions register the native DeepSeek adapter plus p `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). +The Web/headless composition reports session telemetry by default: every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md); the TUI surface does not report. + ## Install (developer machine) Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step: diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 3f5ce7e7a3..bccd944fb9 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -24,6 +24,8 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 +Web/无头组合默认上报会话遥测:每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md);TUI 界面不上报。 + ## 安装(开发机) 将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout,因此代码更改会在下次启动时生效,无需构建: diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index 84e11d1480..5018b8321b 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -111,15 +111,21 @@ # Session telemetry: mirrors every session-log event (assistant/chunk # projected to first-of-step) plus ops markers onto OTLP/HTTP log records, # streaming on the batch processor's cadence (10s/batch here) — not at - # exit; a crash loses at most the last unexported interval. + # exit; a crash loses at most the last unexported interval. No + # telemetry/record redaction rule is mounted yet, so exports are the raw + # captured copy; the deployment stance, env seams, and follow-ups are + # pinned in the web-telemetry-default-mount Agent Note. # DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a - # non-empty DSH_TELEMETRY_DISABLED opts the process out (AppCLIEntry - # patches the row disabled — config cannot disable a row). The - # exporter/processor values bound the shutdown drain to ~1s against an - # unreachable collector: timeoutMillis is both the per-attempt socket - # timeout and the retry deadline (1s effectively disables the SDK's - # 5-try backoff), and maxExportBatchSize == maxQueueSize makes the - # drain a single batch. + # non-empty DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — + # opts the process out (AppCLIEntry patches the row disabled; config + # cannot disable a row). The exporter/processor values bound the + # shutdown drain to ~1s against an unreachable collector: + # exporter.timeoutMillis is both the per-attempt socket timeout and the + # retry deadline (1s effectively disables the SDK's 5-try backoff), + # maxExportBatchSize == maxQueueSize (both explicit) makes the drain a + # single batch, and exportTimeoutMillis is the processor's own cap on + # that one export cycle — the second bound when the exporter's clock + # alone does not fire. - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: @@ -129,6 +135,7 @@ timeoutMillis: 1000 processor: scheduledDelayMillis: 10000 + maxQueueSize: 2048 maxExportBatchSize: 2048 exportTimeoutMillis: 1500 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 09d1fff2fd..3d4afc69ec 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -24,6 +24,9 @@ import type {} from '@deepseek-ai/dsh-host-webserver' const PROFILE_DIR = '.dsh-tmp-profile' const PROFILE_FILE = 'config.json' +/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */ +const TELEMETRY_ROW_ID = 'telemetry-otel' + /** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */ const ALL_INTERFACES_HOST = '0.0.0.0' @@ -59,6 +62,24 @@ export function resolveLanTrust( return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } +/** + * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty + * value (including `'0'`/`'false'`) disables: a privacy switch prefers + * off-by-mistake over on-by-mistake. Throws when the switch is set but the + * row is absent — a silently no-op "disabled" privacy switch would keep + * exporting while the user believes it is off. + * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset). + * @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row. + * @returns the disable patch, or `undefined` when telemetry stays enabled. + */ +export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined { + if ((disabledEnv ?? '') === '') return undefined + if (!hasRow) { + throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`) + } + return { id: TELEMETRY_ROW_ID, disabled: true } +} + /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string @@ -207,9 +228,8 @@ export class AppCLIEntry { // Telemetry opt-out: a row can only be turned off at the patch layer // (config cannot disable an entry), and the switch must hold BEFORE the // plugin constructs — its exporter.url validation is load-time fail-loud. - if ((process.env.DSH_TELEMETRY_DISABLED ?? '') !== '') { - this.patches.push({ id: 'telemetry-otel', disabled: true }) - } + const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) + if (telemetryPatch !== undefined) this.patches.push(telemetryPatch) } /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */ diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 5fef797cc5..3ec2792e8e 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -82,6 +82,17 @@ export async function runHeadless(task: string): Promise { }) const { ctx, port } = await entry.run() const dispose = async (): Promise => { await ctx.fiber.dispose() } + // Signal exits must still dispose the tree: the composition mounts + // exit-drained plugins (telemetry's queued tail and shutdown marker would + // otherwise be lost), and Node's default signal exit skips disposal. + let signalled = false + const disposeAndExit = (code: number): void => { + if (signalled) return + signalled = true + void dispose().finally(() => { process.exit(code) }) + } + process.on('SIGTERM', () => { disposeAndExit(143) }) + process.on('SIGINT', () => { disposeAndExit(130) }) // The headless session is web-observable while it runs (same composition). process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) diff --git a/apps/cli/tests/telemetry-switch.spec.ts b/apps/cli/tests/telemetry-switch.spec.ts new file mode 100644 index 0000000000..0735aa93c7 --- /dev/null +++ b/apps/cli/tests/telemetry-switch.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { resolveTelemetryPatch } from '../src/app-cli-entry.ts' + +describe('resolveTelemetryPatch', () => { + it('keeps telemetry enabled when the switch is unset or empty', () => { + expect(resolveTelemetryPatch(undefined, true)).toBeUndefined() + expect(resolveTelemetryPatch('', true)).toBeUndefined() + }) + + it('disables on ANY non-empty value, including falsy-looking ones', () => { + for (const value of ['1', '0', 'false', 'no']) { + expect(resolveTelemetryPatch(value, true)).toEqual({ id: 'telemetry-otel', disabled: true }) + } + }) + + it('fails loud when the switch is set but the row is absent', () => { + expect(() => resolveTelemetryPatch('1', false)).toThrow('DSH_TELEMETRY_DISABLED is set but row "telemetry-otel" is not in this composition') + }) + + it('ignores a missing row while the switch is unset', () => { + expect(resolveTelemetryPatch(undefined, false)).toBeUndefined() + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 0bab153419..0d53815e09 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -221,6 +221,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Fri, 31 Jul 2026 00:58:36 +0800 Subject: [PATCH 14/22] docs: align bilingual link targets for the telemetry note pair The pairing gate requires both sides of a bilingual pair to link the same target; point the zh side's cross-references at the English canonical files and re-record both i18n pairings. --- .../feature/2026-07-31-web-telemetry-default-mount.i18n.yaml | 2 +- .../feature/2026-07-31-web-telemetry-default-mount.zh.md | 2 +- apps/cli/README.i18n.yaml | 2 +- apps/cli/README.zh.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml index c23829b69a..5fc5d161b4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md 2026-07-31-web-telemetry-default-mount.md: 5c8760388ca8316a0d0a7794cef3ceb24e512e07 -2026-07-31-web-telemetry-default-mount.zh.md: 39c72bb7684768dbb8e1abab6f18801bc33246a0 +2026-07-31-web-telemetry-default-mount.zh.md: 21841dbf5f205395248267a140851e2db71af4a7 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md index 39c72bb768..21841dbf5f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -遥测 seam 与 OTel backend([revival Note](2026-07-23-session-telemetry-otel-revival.zh.md))自完成以来从未接入任何部署组合:没有 roster 行、没有开关、没有节奏口径,内部部署对用户会话零可观测。需要一个部署决策:哪些 surface 上报、报到哪、什么节奏、怎么关、CI 怎么隔离。 +遥测 seam 与 OTel backend([revival Note](2026-07-23-session-telemetry-otel-revival.md))自完成以来从未接入任何部署组合:没有 roster 行、没有开关、没有节奏口径,内部部署对用户会话零可观测。需要一个部署决策:哪些 surface 上报、报到哪、什么节奏、怎么关、CI 怎么隔离。 ## Decision diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 2489594cd2..813ede56a6 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md README.md: f7b5fb09cacbdaea013a8da433c09da74db928ab -README.zh.md: bccd944fb96b270427d59c05c4e04760cd68a90d +README.zh.md: 13d70495736d4505a573aa4818d6289c6b0d1924 diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index bccd944fb9..13d7049573 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -24,7 +24,7 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 -Web/无头组合默认上报会话遥测:每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md);TUI 界面不上报。 +Web/无头组合默认上报会话遥测:每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md);TUI 界面不上报。 ## 安装(开发机) From faaed567199ceaa9d457dc7d2e85523e4652adce Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 15:47:25 +0800 Subject: [PATCH 15/22] fix: node-addon bump version --- packages/sdk/scripts/package.json | 2 +- pnpm-lock.yaml | 118 +++++++++++++++--------------- vendor/loader/package.json | 2 +- 3 files changed, 61 insertions(+), 61 deletions(-) diff --git a/packages/sdk/scripts/package.json b/packages/sdk/scripts/package.json index 63afe96d08..e4bb35491c 100644 --- a/packages/sdk/scripts/package.json +++ b/packages/sdk/scripts/package.json @@ -39,7 +39,7 @@ "@deepseek-ai/dsh-helper": "workspace:^", "@deepseek-ai/dsh-telemetry": "workspace:^", "commander": "^15.0.0", - "node-addon-require-builtin": "^0.1.0" + "node-addon-require-builtin": "^0.1.3" }, "peerDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c19bae4228..4264805360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2165,7 +2165,7 @@ importers: devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) '@cordisjs/plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer @@ -3912,8 +3912,8 @@ importers: specifier: ^15.0.0 version: 15.0.0 node-addon-require-builtin: - specifier: ^0.1.0 - version: 0.1.0 + specifier: ^0.1.3 + version: 0.1.3 devDependencies: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ @@ -4643,7 +4643,7 @@ importers: devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4680,7 +4680,7 @@ importers: devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4720,7 +4720,7 @@ importers: devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4805,7 +4805,7 @@ importers: devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4857,7 +4857,7 @@ importers: devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6231,7 +6231,7 @@ importers: version: 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -6245,7 +6245,7 @@ importers: dependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -6288,7 +6288,7 @@ importers: dependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -6308,8 +6308,8 @@ importers: specifier: ^1.8.1 version: 1.8.1 node-addon-require-builtin: - specifier: ^0.1.0 - version: 0.1.0 + specifier: ^0.1.3 + version: 0.1.3 vendor/logger-console: dependencies: @@ -10471,56 +10471,56 @@ packages: resolution: {integrity: sha512-c5qopltRonjW6+VinXYMp4FVi9Sxf8eQEb/9G82EksQQ+JC4CDprv+ko5URWmtyZ3wD4GFdeRTyw1AG5yCwJhQ==} engines: {node: '>=20'} - node-addon-native-custom-loader@0.1.0: - resolution: {integrity: sha512-LtkRZWBshiGdWB9K7yuQuEeQaoYfWSFMwV52wi4kKsQSRCSjB4Sf4lEgQTiOlVmOznM0Bg9EABLKBowkCDucQQ==} + node-addon-native-custom-loader@0.1.3: + resolution: {integrity: sha512-uMG8D3aOtEMgh7dkNWAJP0fSpmpMwUf6Cj5JePQQqtxt72sW7RDzRetaLjKIKl3+DZtBX2FobAgRS6U3LO2qXQ==} engines: {node: '>=20'} - node-addon-require-builtin-darwin-arm64@0.1.0: - resolution: {integrity: sha512-KXmOO2gs5um5HXt8k9sZMnNwrgTdnRKOf5NMXLyrY6pc3QCJrdCY6J6STgurjoM4GXOJlfo2emEfOxrIS0sCbg==} + node-addon-require-builtin-darwin-arm64@0.1.3: + resolution: {integrity: sha512-uBZIRpq3gVG/lg4SV1w8xNfgoaAWZiv7B8Gn38/wd0uUE0LSTRikY69al3Yb4gMDmJPWBoXPN3gzTxAjhllzGg==} engines: {node: '>=20'} cpu: [arm64] os: [darwin] - node-addon-require-builtin-darwin-x64@0.1.0: - resolution: {integrity: sha512-m1JkvBslC4ooNUvlvQoOx96d0qk2M1e+bO2gVkl+TT0SD07VGAPXNkxZvyT+O3A63i/1j0rjJ88B78sSdyDiVA==} + node-addon-require-builtin-darwin-x64@0.1.3: + resolution: {integrity: sha512-BLaBoaBjI7mpsgTpXvn444vCQSmrb1AC2t0tAHFuxwBtN56UT9Zaxl+gS2KZGrq5fShTPxFAUIiTcwroFXWWhg==} engines: {node: '>=20'} cpu: [x64] os: [darwin] - node-addon-require-builtin-linux-arm64-gnu@0.1.0: - resolution: {integrity: sha512-76fYWMzYBeT6eunBUrxAUleyMZwQfp8FgB3XLDCrQAsDtH0UVdg+zgmbVIQeJyJQdxTnfsQ9sfvdymG96ZeZew==} + node-addon-require-builtin-linux-arm64-gnu@0.1.3: + resolution: {integrity: sha512-L+qNUfBarYxE0HSZjf2KGymS6ZKMieLs5esRXbAbO+q1k4L1t9oBNGpQuFe7/a1a98YrfHnkAg9Q6US5j/xnIw==} engines: {node: '>=20'} cpu: [arm64] os: [linux] libc: [glibc] - node-addon-require-builtin-linux-x64-gnu@0.1.0: - resolution: {integrity: sha512-dDOumCPgJheVfcHOVq2nCQUp3mRU0Qsu6MfnZzVZMA73tSeQwA+yoUuQW3oPz/wuE51LwXEkm6Se9aerawi0Ng==} + node-addon-require-builtin-linux-x64-gnu@0.1.3: + resolution: {integrity: sha512-Cy2ua4yy44GE5HAtf/o4LjzTa5aUJt5m0YLMjZCa8lRte5hU+C7aWm6bVkmKY82b1JnnQnHwUMEoFugLqVybSQ==} engines: {node: '>=20'} cpu: [x64] os: [linux] libc: [glibc] - node-addon-require-builtin-win32-arm64-msvc@0.1.0: - resolution: {integrity: sha512-OJ7m8r074Wbtc8mLsh+ugIP4KCwsTyDzfB7FE+7eeSSYRgQlbSOC11jMOYIWqMalLhAWCLkRBw7fYJDty3sSAw==} + node-addon-require-builtin-win32-arm64-msvc@0.1.3: + resolution: {integrity: sha512-8j/VcAmgT6HPQzwUo1kBNzLE2d5iVmwfraEre5KAoznuBeOiuU12oqDYpkuHGIzSjSDJiVOj/SqOe5mUMRaZOg==} engines: {node: '>=20'} cpu: [arm64] os: [win32] - node-addon-require-builtin-win32-ia32-msvc@0.1.0: - resolution: {integrity: sha512-qUhC7MEP0NhuNMwlnPudYIBtPKlUo9McRi3PWsv4539hFar1RfxGmU4DZYtDQk07Bms/NAlIE8X7mxKVu8E+OQ==} + node-addon-require-builtin-win32-ia32-msvc@0.1.3: + resolution: {integrity: sha512-Iqh+Wxmbu6SaP2lEJpEpIMkusVZeVljn914CIcz7HZtzvxTgxHZAmfsZuRMDtRhDg2Yf4AFBHLWpcJn7SeBQ/A==} engines: {node: '>=20 <23'} cpu: [ia32] os: [win32] - node-addon-require-builtin-win32-x64-msvc@0.1.0: - resolution: {integrity: sha512-JHiuwzW6jz6K8UxzoFmthDCUyZcbXlPIim0LuH7rlgz7ebVW7791lJThZp4WYGHWMiHzhljEfVG9YW4DuEwEmA==} + node-addon-require-builtin-win32-x64-msvc@0.1.3: + resolution: {integrity: sha512-5iI7C/BwwRemDNKXO2b1J/iK1gTRp1278Cwfoy92zgn7KXhv7xsAP8klk/fDu8RWX/o9zk736tRSFTBBGSHf/Q==} engines: {node: '>=20'} cpu: [x64] os: [win32] - node-addon-require-builtin@0.1.0: - resolution: {integrity: sha512-HGlhjpNtFP7qtbBIBQ2+eXDe1qXcX4RQa426IMQ+SKoLCQS9AcHYl0kwJCERvG821wfRlJOzGBoREtBOwvUGeg==} + node-addon-require-builtin@0.1.3: + resolution: {integrity: sha512-u9ZRdwDCx+ksIcYwoLeoe5Rj3151GrzSF8ln9jp7P/Zhf0OrPs1X6a8wYw6brc532ypAwjwKlhruT/V3m8MCbg==} engines: {node: '>=20'} node-domexception@1.0.0: @@ -12153,7 +12153,7 @@ snapshots: '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)': dependencies: - '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: 1.8.1 js-yaml: 4.2.0 @@ -12166,12 +12166,12 @@ snapshots: js-yaml: 4.2.0 optional: true - '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)': + '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3)': dependencies: cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: 1.8.1 optionalDependencies: - node-addon-require-builtin: 0.1.0 + node-addon-require-builtin: 0.1.3 '@cordisjs/plugin-timer@1.1.2(cordis@4.0.0-rc.7)': dependencies: @@ -14119,7 +14119,7 @@ snapshots: cosmokit: 1.8.1 optionalDependencies: '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) - '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader): dependencies: @@ -15812,54 +15812,54 @@ snapshots: node-addon-landlock-run-linux-arm64: 0.0.0-test.0 node-addon-landlock-run-linux-x64: 0.0.0-test.0 - node-addon-native-custom-loader@0.1.0: {} + node-addon-native-custom-loader@0.1.3: {} - node-addon-require-builtin-darwin-arm64@0.1.0: + node-addon-require-builtin-darwin-arm64@0.1.3: dependencies: - node-addon-native-custom-loader: 0.1.0 + node-addon-native-custom-loader: 0.1.3 optional: true - node-addon-require-builtin-darwin-x64@0.1.0: + node-addon-require-builtin-darwin-x64@0.1.3: dependencies: - node-addon-native-custom-loader: 0.1.0 + node-addon-native-custom-loader: 0.1.3 optional: true - node-addon-require-builtin-linux-arm64-gnu@0.1.0: + node-addon-require-builtin-linux-arm64-gnu@0.1.3: dependencies: - node-addon-native-custom-loader: 0.1.0 + node-addon-native-custom-loader: 0.1.3 optional: true - node-addon-require-builtin-linux-x64-gnu@0.1.0: + node-addon-require-builtin-linux-x64-gnu@0.1.3: dependencies: - node-addon-native-custom-loader: 0.1.0 + node-addon-native-custom-loader: 0.1.3 optional: true - node-addon-require-builtin-win32-arm64-msvc@0.1.0: + node-addon-require-builtin-win32-arm64-msvc@0.1.3: dependencies: - node-addon-native-custom-loader: 0.1.0 + node-addon-native-custom-loader: 0.1.3 optional: true - node-addon-require-builtin-win32-ia32-msvc@0.1.0: + node-addon-require-builtin-win32-ia32-msvc@0.1.3: dependencies: - node-addon-native-custom-loader: 0.1.0 + node-addon-native-custom-loader: 0.1.3 optional: true - node-addon-require-builtin-win32-x64-msvc@0.1.0: + node-addon-require-builtin-win32-x64-msvc@0.1.3: dependencies: - node-addon-native-custom-loader: 0.1.0 + node-addon-native-custom-loader: 0.1.3 optional: true - node-addon-require-builtin@0.1.0: + node-addon-require-builtin@0.1.3: dependencies: - node-addon-native-custom-loader: 0.1.0 + node-addon-native-custom-loader: 0.1.3 optionalDependencies: - node-addon-require-builtin-darwin-arm64: 0.1.0 - node-addon-require-builtin-darwin-x64: 0.1.0 - node-addon-require-builtin-linux-arm64-gnu: 0.1.0 - node-addon-require-builtin-linux-x64-gnu: 0.1.0 - node-addon-require-builtin-win32-arm64-msvc: 0.1.0 - node-addon-require-builtin-win32-ia32-msvc: 0.1.0 - node-addon-require-builtin-win32-x64-msvc: 0.1.0 + node-addon-require-builtin-darwin-arm64: 0.1.3 + node-addon-require-builtin-darwin-x64: 0.1.3 + node-addon-require-builtin-linux-arm64-gnu: 0.1.3 + node-addon-require-builtin-linux-x64-gnu: 0.1.3 + node-addon-require-builtin-win32-arm64-msvc: 0.1.3 + node-addon-require-builtin-win32-ia32-msvc: 0.1.3 + node-addon-require-builtin-win32-x64-msvc: 0.1.3 node-domexception@1.0.0: {} diff --git a/vendor/loader/package.json b/vendor/loader/package.json index ad5f14f7cd..c7bbaf5176 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -24,7 +24,7 @@ "license": "MIT", "peerDependencies": { "cordis": "^4.0.0-rc.7", - "node-addon-require-builtin": "^0.1.0" + "node-addon-require-builtin": "^0.1.3" }, "peerDependenciesMeta": { "node-addon-require-builtin": { From d802364651b8281f7fdc93f98acfbf5ed220a1a6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:33:33 +0800 Subject: [PATCH 16/22] =?UTF-8?q?feat(cli):=20move=20the=20telemetry=20row?= =?UTF-8?q?=20into=20the=20shared=20base=20=E2=80=94=20every=20surface=20r?= =?UTF-8?q?eports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row moves from web.cordis.yml to base.cordis.yml, so the TUI reports too (its exit paths already drain: disposeRootAndExit on normal exit, root dispose before the /resume execve). The TUI launcher applies the same resolveTelemetryPatch opt-out, judged against the tree actually booting via configHasTelemetryRow so a --config-replace tree without the row is not failed by a switch with nothing to disable. The TUI keyless smoke disables telemetry in its child env; README (en/zh) and the Agent Note pair updated to the every-surface stance. --- ...7-31-web-telemetry-default-mount.i18n.yaml | 4 +-- .../2026-07-31-web-telemetry-default-mount.md | 6 ++-- ...26-07-31-web-telemetry-default-mount.zh.md | 6 ++-- apps/cli/README.i18n.yaml | 4 +-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/config/base.cordis.yml | 32 +++++++++++++++++++ apps/cli/config/web.cordis.yml | 31 ------------------ apps/cli/src/app-cli-entry.ts | 14 ++++++++ apps/cli/src/tui.ts | 23 +++++++++---- apps/cli/tests/tui-keyless-smoke.e2e.ts | 4 ++- 11 files changed, 78 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml index 5fc5d161b4..97793a906a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md -2026-07-31-web-telemetry-default-mount.md: 5c8760388ca8316a0d0a7794cef3ceb24e512e07 -2026-07-31-web-telemetry-default-mount.zh.md: 21841dbf5f205395248267a140851e2db71af4a7 +2026-07-31-web-telemetry-default-mount.md: 6c1fdaa8719ee01726b51db9a469ff659cbac476 +2026-07-31-web-telemetry-default-mount.zh.md: b447832527ba9731097cd0776060db11ee4dfc30 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md index 5c8760388c..6c1fdaa871 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md @@ -10,11 +10,11 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry ## Decision -The shared web/headless composition (`apps/cli/config/web.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint; this is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. The TUI composition stays unmounted (its normal exit path never disposes the root fiber, so mounting before that drain semantic is resolved would misreport every clean TUI exit as a crash). +The shared `dsh` core (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so every surface — TUI, web, and headless — reports; this is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Each surface's exit path drains the queue: web/headless dispose on SIGINT/SIGTERM (headless gained those handlers in this change), and the TUI's normal exit runs `disposeRootAndExit` (root dispose, 5s bounded — above the ~1s drain ceiling configured here) while its `/resume` handoff disposes the root before `execve`. | Ruling | Value | Rationale | |---|---|---| -| Mount surface | web.cordis.yml insert block (web + headless share it) | Both surfaces boot the same tree; the TUI deliberately stays out | +| Mount surface | base.cordis.yml (TUI + web + headless) | One deployment stance for every surface; per-surface divergence would need a reason, and none exists | | Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs | | Opt-out switch | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables | A privacy switch prefers off-by-mistake over on-by-mistake; a row can only be disabled at AppCLIEntry's patch layer (config has no disable semantic, and the switch must precede the load-time `exporter.url` validation) | | Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval | @@ -35,5 +35,5 @@ The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the depl ## Consequences - A developer running `dsh web` without a local collector POSTs to the production endpoint every 10s (silent failure when unreachable; no OTel diag logger is registered); local development sets `DSH_TELEMETRY_DISABLED=1` or points `DSH_TELEMETRY_OTLP_URL` locally. -- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, identity Resource attributes (hostname / anonymous user id / surface), TUI adoption, and the usage-metrics track are the explicit follow-ups of this decision. +- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, identity Resource attributes (hostname / anonymous user id / surface), and the usage-metrics track are the explicit follow-ups of this decision. - Test rigs reusing this tree (e.g. `apps/web/tests/scaffold.ts`) must explicitly disable the row, or fixture sessions stream to whatever collector the environment happens to name. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md index 21841dbf5f..b447832527 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -10,11 +10,11 @@ Status: implemented ## Decision -Web/headless 共享组合(`apps/cli/config/web.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint;这是**内部测试期的部署立场**——有 endpoint 就报,用户可经环境变量退出。TUI 组合暂不挂载(其正常退出路径不经根 fiber dispose,drain 语义未解决前接入会把每次正常退出误报为 crash)。 +`dsh` 共享核心(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此所有 surface——TUI、web、headless——都上报;这是**内部测试期的部署立场**——有 endpoint 就报,用户可经环境变量退出。各 surface 的退出路径都会排空队列:web/headless 在 SIGINT/SIGTERM 上 dispose(headless 的信号处理是本次补上的),TUI 的正常退出走 `disposeRootAndExit`(根 dispose,5s 兜底——高于此处配置的 ~1s drain 上界),其 `/resume` 移交也在 `execve` 前 dispose 根。 | 决策项 | 取值 | 理由 | |---|---|---| -| 挂载面 | web.cordis.yml 的 insert 块(web + headless 共享) | 两 surface 同一棵树;TUI 明确不挂 | +| 挂载面 | base.cordis.yml(TUI + web + headless) | 所有 surface 一个部署立场;按 surface 分化需要理由,而当前没有 | | endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 | | 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做(config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) | | 上报节奏 | `processor.scheduledDelayMillis: 10000`(10s/批) | 流式回流,非退出才报;崩溃至多丢最后一个未导出间隔 | @@ -35,5 +35,5 @@ Web/headless 共享组合(`apps/cli/config/web.cordis.yml`)默认挂载 `tel ## Consequences - 无本地 collector 的开发者跑 `dsh web` 会对生产 endpoint 每 10s 发一次 POST(联不通则静默失败,OTel diag logger 未注册);本地开发设 `DSH_TELEMETRY_DISABLED=1` 或 `DSH_TELEMETRY_OTLP_URL` 指本地。 -- **当前零脱敏规则挂载**:导出即原始捕获副本(用户/助手消息全文、工具参数与结果、system prompt、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、身份 Resource 维度(hostname/匿名 user id/surface)、TUI 接入、使用数据 metrics 轨四件是本决策明确的后续工作。 +- **当前零脱敏规则挂载**:导出即原始捕获副本(用户/助手消息全文、工具参数与结果、system prompt、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、身份 Resource 维度(hostname/匿名 user id/surface)、使用数据 metrics 轨三件是本决策明确的后续工作。 - 复用这棵树的测试载具(如 `apps/web/tests/scaffold.ts`)须显式关停该行,否则 fixture 会话会流向 env 里碰巧存在的 collector。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 813ede56a6..26395105b7 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: f7b5fb09cacbdaea013a8da433c09da74db928ab -README.zh.md: 13d70495736d4505a573aa4818d6289c6b0d1924 +README.md: e56b726029c5bba9ba769c6dd3493d913f0129d7 +README.zh.md: 24ff9a6e8d48016d213e877e23768332d86cccde diff --git a/apps/cli/README.md b/apps/cli/README.md index f7b5fb09ca..e56b726029 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -24,7 +24,7 @@ The shipped TUI and Web compositions register the native DeepSeek adapter plus p `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). -The Web/headless composition reports session telemetry by default: every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md); the TUI surface does not report. +Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). ## Install (developer machine) diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 13d7049573..24ff9a6e8d 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -24,7 +24,7 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 -Web/无头组合默认上报会话遥测:每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md);TUI 界面不上报。 +每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 ## 安装(开发机) diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 16d4e12471..20d3255a7c 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -94,6 +94,38 @@ config: path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db' +# Session telemetry, on for every dsh surface: mirrors every session-log +# event (assistant/chunk projected to first-of-step) plus ops markers onto +# OTLP/HTTP log records, streaming on the batch processor's cadence +# (10s/batch here) — not at exit; a crash loses at most the last unexported +# interval. No telemetry/record redaction rule is mounted yet, so exports +# are the raw captured copy; the deployment stance, env seams, and +# follow-ups are pinned in the web-telemetry-default-mount Agent Note. +# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty +# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the +# process out (the launchers patch the row disabled; config cannot disable +# a row). The exporter/processor values bound the shutdown drain to ~1s +# against an unreachable collector: exporter.timeoutMillis is both the +# per-attempt socket timeout and the retry deadline (1s effectively +# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize +# (both explicit) makes the drain a single batch, and exportTimeoutMillis +# is the processor's own cap on that one export cycle — the second bound +# when the exporter's clock alone does not fire. Every surface's exit path +# drains it: web/headless dispose on SIGINT/SIGTERM, and the TUI's normal +# exit and /resume handoff both dispose the root. +- id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: + url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs' + compression: gzip + timeoutMillis: 1000 + processor: + scheduledDelayMillis: 10000 + maxQueueSize: 2048 + maxExportBatchSize: 2048 + exportTimeoutMillis: 1500 + - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index 5018b8321b..a2fc10804d 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -108,37 +108,6 @@ writeEveryEvents: 200 writeIntervalMs: 5000 - # Session telemetry: mirrors every session-log event (assistant/chunk - # projected to first-of-step) plus ops markers onto OTLP/HTTP log records, - # streaming on the batch processor's cadence (10s/batch here) — not at - # exit; a crash loses at most the last unexported interval. No - # telemetry/record redaction rule is mounted yet, so exports are the raw - # captured copy; the deployment stance, env seams, and follow-ups are - # pinned in the web-telemetry-default-mount Agent Note. - # DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a - # non-empty DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — - # opts the process out (AppCLIEntry patches the row disabled; config - # cannot disable a row). The exporter/processor values bound the - # shutdown drain to ~1s against an unreachable collector: - # exporter.timeoutMillis is both the per-attempt socket timeout and the - # retry deadline (1s effectively disables the SDK's 5-try backoff), - # maxExportBatchSize == maxQueueSize (both explicit) makes the drain a - # single batch, and exportTimeoutMillis is the processor's own cap on - # that one export cycle — the second bound when the exporter's clock - # alone does not fire. - - id: telemetry-otel - name: '@deepseek-ai/dsh-session-telemetry-otel' - config: - exporter: - url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs' - compression: gzip - timeoutMillis: 1000 - processor: - scheduledDelayMillis: 10000 - maxQueueSize: 2048 - maxExportBatchSize: 2048 - exportTimeoutMillis: 1500 - - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 3d4afc69ec..6ac88d23c5 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -80,6 +80,20 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b return { id: TELEMETRY_ROW_ID, disabled: true } } +/** + * Whether a config file carries the telemetry row, parsed under the same + * `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers + * that compose their patch lists outside {@link AppCLIEntry} (the TUI). + * @param file - absolute path of the config or overlay file. + * @returns true when a top-level (or inserted) row has the telemetry id. + */ +export function configHasTelemetryRow(file: string): boolean { + const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`) + return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row => + row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID)) +} + /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 3469a737c1..dee865a0e1 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -31,6 +31,7 @@ import { resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' +import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts' import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite' import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import type { Context } from 'cordis' @@ -196,16 +197,26 @@ export async function runTui( // demo or test config would silently run on the user's provider and model. // `--config-replace` additionally discards the base and the surface overlay. const replaceTree = configReplace !== undefined - const patches = replaceTree ? [] : [ - ...loadOverlayPatches(NAME, TUI_OVERLAY), - ...resolvedConfig === undefined - ? loadPersonalPatches(NAME) ?? [] - : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), + const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined) + // Same opt-out semantics as the web surface (resolveTelemetryPatch: any + // non-empty value disables; setting the switch against a tree without the + // row fails loud rather than silently no-opping a privacy switch). The row + // presence is checked against the tree actually booting, so a + // --config-replace tree is judged on its own rows, not the shipped base's. + const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig)) + const patches = [ + ...replaceTree ? [] : [ + ...loadOverlayPatches(NAME, TUI_OVERLAY), + ...resolvedConfig === undefined + ? loadPersonalPatches(NAME) ?? [] + : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), + ], + ...telemetryPatch === undefined ? [] : [telemetryPatch], ] const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) const ctx = await boot( NAME, - resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined), + bootConfig, patches, (hostCtx) => { // The launcher owns session identity and the exit line: a config-mounted diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index d8966ee081..359fe6338e 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -130,7 +130,9 @@ function smoke(overrides: Partial & { label: string }): Prom tempDirPrefix: 'dsh-tui-smoke-', binScript: dshBinScript, tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, + // Telemetry now mounts in the shared base: keep fixture sessions from + // POSTing to the production endpoint when run outside CI's workflow env. + env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call', DSH_TELEMETRY_DISABLED: '1' }, // Artifact CI builds and smokes concurrently on a contended runner. ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), ...overrides, From 9adee1eeb1c6bfa65f895092bb852fc2c2ea0152 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:38:34 +0800 Subject: [PATCH 17/22] fix: lint --- apps/cli/composition.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 2e71c6c07b..870b926054 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -38,6 +38,8 @@ flowchart LR cfg --> plugin_tui_session_persistence_jsonl plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] cfg --> plugin_tui_session_query_sqlite + plugin_tui_telemetry_otel["telemetry-otel
@deepseek-ai/dsh-session-telemetry-otel"] + cfg --> plugin_tui_telemetry_otel plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] cfg --> plugin_tui_subprocess plugin_tui_bash_local["bash-local
@deepseek-ai/dsh-bash-local"] @@ -123,6 +125,7 @@ flowchart LR | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | +| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash-local` | `@deepseek-ai/dsh-bash-local` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` | From 1e10966ef65fbb5beb2283e36c9cdbc65b13bfb8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:56:41 +0800 Subject: [PATCH 18/22] wip fix: docs --- ...07-30-client-locale-full-rollout.i18n.yaml | 6 + .../2026-07-30-client-locale-full-rollout.md | 45 +++++ ...026-07-30-client-locale-full-rollout.zh.md | 45 +++++ ...-25-client-settings-locale-theme.i18n.yaml | 6 +- ...2026-07-25-client-settings-locale-theme.md | 2 +- ...6-07-25-client-settings-locale-theme.zh.md | 2 +- apps/web/tests/built-boot.snapshot.ts | 3 + .../tests/details-session-lifecycle.e2e.ts | 8 +- apps/web/tests/message-actions.e2e.ts | 10 +- apps/web/tests/navigation-panes.e2e.ts | 4 +- apps/web/tests/queue-actions.e2e.ts | 14 +- apps/web/tests/seeded-history.e2e.ts | 4 +- .../snapshots/code-mode-round/ui.expected.md | 10 +- .../cordis-tool-round/ui.expected.md | 12 +- .../snapshots/fresh-round-trip/ui.expected.md | 14 +- .../lifecycle-chrome/hero.expected.md | 6 +- .../lifecycle-chrome/reloaded.expected.md | 10 +- .../live-interactions/cancel.expected.md | 12 +- .../live-interactions/error-auth.expected.md | 6 +- .../live-interactions/retry.expected.md | 10 +- .../snapshots/message-actions/ui.expected.md | 16 +- .../terminal-card.expected.md | 4 +- .../plan-review/approved.expected.md | 10 +- .../question-composer/answered.expected.md | 10 +- .../queue-actions/collapsed.expected.md | 8 +- .../queue-actions/editing.expected.md | 18 +- .../snapshots/queue-actions/ui.expected.md | 10 +- .../snapshots/seeded-history/ui.expected.md | 18 +- .../snapshots/steering/mid-steer.expected.md | 6 +- .../snapshots/steering/settled.expected.md | 12 +- apps/web/tests/steering.e2e.ts | 6 +- docs/module-graph.md | 54 +++--- packages/client/test-runtime/src/index.ts | 1 + packages/client/test-runtime/src/translate.ts | 32 ++++ packages/client/ui-command/package.json | 4 + .../ui-command/src/client/PopupSelectView.tsx | 24 +-- .../client/ui-command/src/client/index.ts | 22 ++- .../client/ui-command/src/client/locales.ts | 26 +++ .../ui-command/tests/browser-plugin.spec.ts | 4 +- .../ui-command/tests/popup-view.spec.tsx | 28 +-- packages/client/ui-command/tsconfig.json | 3 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 55 +++--- .../src/client/chat/AssistantMarkdown.tsx | 33 +++- .../src/client/chat/ChatView.tsx | 50 ++++-- .../src/client/chat/ContextInjectionRow.tsx | 11 +- .../src/client/chat/GenericCommandCard.tsx | 16 +- .../src/client/chat/GenericToolCard.tsx | 10 +- .../src/client/chat/MessageIconActions.tsx | 19 +- .../src/client/chat/MessageItem.tsx | 17 +- .../src/client/chat/ToolRow.tsx | 17 +- .../src/client/chat/message-chrome.ts | 21 ++- .../src/client/contract/slots.ts | 32 ++-- .../client/contract/terminal-card-model.ts | 28 ++- .../ui-conversation/src/client/index.ts | 1 + .../ui-conversation/src/client/locales.ts | 170 ++++++++++++++++++ .../src/client/queue/QueueDock.tsx | 34 ++-- .../src/client/skeleton/ApprovalPanel.tsx | 18 +- .../src/client/skeleton/ConversationRoot.tsx | 9 +- .../client/skeleton/ConversationSession.tsx | 4 +- .../src/client/skeleton/DetailsPanel.tsx | 27 +-- .../src/client/skeleton/EmptyHero.tsx | 17 +- .../src/client/skeleton/InputBar.tsx | 24 +-- .../src/client/skeleton/PermissionSelect.tsx | 7 +- .../src/client/skeleton/TodoPanel.tsx | 31 ++-- .../src/client/toolviews/ask-question-row.tsx | 26 +-- .../src/client/toolviews/bash-sample.tsx | 28 ++- .../src/client/toolviews/todo-row.tsx | 22 ++- .../tests/apply-inject.spec.tsx | 6 +- .../tests/ask-question-row.spec.tsx | 32 ++-- .../tests/assembly-surfaces.spec.tsx | 18 +- .../ui-conversation/tests/chat-apply.spec.tsx | 8 +- .../tests/chat-branch-tails.spec.tsx | 36 ++-- .../tests/chat-code-subcalls.spec.tsx | 4 +- .../tests/chat-stats-bash-sample.spec.tsx | 14 +- .../tests/chat-tool-row.spec.tsx | 15 +- .../tests/chat-toolview-slot.spec.tsx | 8 +- .../ui-conversation/tests/chat-view.spec.tsx | 5 + .../tests/coverage-tails.spec.tsx | 26 ++- .../tests/gate-branch-tails.spec.tsx | 13 +- .../ui-conversation/tests/input-bar.spec.tsx | 56 +++--- .../tests/input-matrix.spec.tsx | 11 +- .../tests/input-scenarios.spec.tsx | 9 +- .../ui-conversation/tests/queue-dock.spec.tsx | 9 +- .../ui-conversation/tests/skeleton.spec.tsx | 20 ++- .../tests/terminal-card.spec.tsx | 31 ++-- .../ui-conversation/tests/todo-panel.spec.tsx | 40 +++-- packages/client/ui-goal/package.json | 4 + .../client/ui-goal/src/client/GoalBar.tsx | 43 ++--- packages/client/ui-goal/src/client/index.ts | 21 ++- packages/client/ui-goal/src/client/locales.ts | 32 ++++ .../ui-goal/tests/browser-plugin.spec.tsx | 14 +- .../client/ui-goal/tests/goalbar.spec.tsx | 118 ++++++------ packages/client/ui-goal/tsconfig.json | 3 + packages/client/ui-models/src/client/index.ts | 34 ++-- .../client/ui-models/src/client/locales.ts | 5 +- packages/client/ui-models/tests/apply.spec.ts | 15 +- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 2 +- packages/client/ui-plan/README.zh.md | 2 +- packages/client/ui-plan/package.json | 4 + .../ui-plan/src/client/PlanModeControl.tsx | 18 +- packages/client/ui-plan/src/client/index.ts | 30 +++- packages/client/ui-plan/src/client/locales.ts | 20 +++ .../ui-plan/tests/browser-plugin.spec.ts | 9 +- .../ui-plan/tests/plan-mode-control.spec.tsx | 16 +- packages/client/ui-plan/tsconfig.json | 3 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/ConnectionBanner.tsx | 9 +- .../client/ui-primitives/src/JsonTree.tsx | 94 ++++++++-- packages/client/ui-primitives/src/Modal.tsx | 2 + .../ui-primitives/src/TerminalBlock.tsx | 86 +++++++-- packages/client/ui-primitives/src/index.ts | 6 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 8 +- .../ui-primitives/src/markdown/JsonBlock.tsx | 13 +- .../src/markdown/MarkdownText.tsx | 41 ++++- .../src/client/GeneralSection.tsx | 12 +- .../ui-settings-general/src/client/chrome.tsx | 20 +-- .../ui-settings-general/src/client/index.ts | 54 +++--- .../ui-settings-general/src/client/locales.ts | 19 +- .../ui-settings-general/tests/apply.spec.ts | 40 ++--- .../tests/components.spec.tsx | 4 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- packages/client/ui-settings/package.json | 1 + .../client/ui-settings/src/client/index.ts | 25 ++- packages/client/ui-settings/tsconfig.json | 3 + .../sidebar-snapshot.spec.tsx.snap | 70 ++++++++ .../tests/sidebar-snapshot.spec.tsx | 40 ++++- packages/client/ui-slash/package.json | 1 + .../client/ui-slash/src/client/MenuView.tsx | 16 +- packages/client/ui-slash/src/client/index.ts | 22 ++- .../client/ui-slash/src/client/locales.ts | 26 +++ packages/client/ui-slash/src/client/slots.ts | 9 +- packages/client/ui-slash/tests/apply.spec.ts | 4 +- .../client/ui-slash/tests/menu-view.spec.tsx | 15 +- packages/client/ui-slots/src/index.ts | 24 ++- .../ui-trajectory/tests/client-bundle.spec.ts | 9 +- .../client/ui-trajectory/tests/views.spec.tsx | 11 +- packages/client/ui-workspace/package.json | 4 + .../src/client/WorkspaceBrowser.tsx | 91 ++++++---- .../src/client/WorkspacePicker.tsx | 37 ++-- .../ui-workspace/src/client/contract/slots.ts | 12 +- .../client/ui-workspace/src/client/index.ts | 20 ++- .../client/ui-workspace/src/client/locales.ts | 118 ++++++++++++ .../ui-workspace/src/client/rows/Rows.tsx | 98 ++++++---- .../client/ui-workspace/src/client/tree.ts | 45 +++-- packages/client/ui-workspace/src/invariant.ts | 8 +- .../client/ui-workspace/tests/apply.spec.ts | 11 +- .../tests/rename-assembly.spec.tsx | 34 ++-- .../client/ui-workspace/tests/rows.spec.tsx | 88 ++++----- .../client/ui-workspace/tests/tree.spec.ts | 48 +++-- .../tests/workspace-browser.spec.tsx | 168 +++++++++-------- .../tests/workspace-picker.spec.tsx | 106 ++++++----- pnpm-lock.yaml | 30 ++++ 160 files changed, 2521 insertions(+), 1135 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md create mode 100644 packages/client/test-runtime/src/translate.ts create mode 100644 packages/client/ui-command/src/client/locales.ts create mode 100644 packages/client/ui-conversation/src/client/locales.ts create mode 100644 packages/client/ui-goal/src/client/locales.ts create mode 100644 packages/client/ui-plan/src/client/locales.ts create mode 100644 packages/client/ui-slash/src/client/locales.ts create mode 100644 packages/client/ui-workspace/src/client/locales.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml new file mode 100644 index 0000000000..a56a91c980 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +2026-07-30-client-locale-full-rollout.md: c080d9f240d4533ecd9694ceecfada8662c46425 +2026-07-30-client-locale-full-rollout.zh.md: 062d982e3d7ea62f3ca4c8fedb842e8336f0852c diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md new file mode 100644 index 0000000000..c080d9f240 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -0,0 +1,45 @@ +# Agent Note: Full client copy rollout onto the typed locale seat, and the non-translation boundary + +Status: implemented + +English | [中文](2026-07-30-client-locale-full-rollout.zh.md) + +## Problem + +After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms and boundary decisions the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch; how the zero-cordis ui-primitives atoms receive copy; and which strings deliberately stay untranslated — an unrecorded boundary invites a future agent to "complete" the localization. + +## Decision + +**Registration-time text rides a label thunk.** A list registration's `label` accepts `SlotLabel = string | (() => string)`; owners projecting ledger rows resolve through `resolveSlotLabel` (never reading `options.label` raw) and make the read point follow the locale revision (outlets subscribe to the revision themselves; off-ledger projections such as the ui-settings nav fold the revision into their cache key and subscribe to both sources). Thunks evaluate per read, so a language switch causes zero ledger churn — no re-registration, versions stay put, and every `locale/change` re-registration wiring is deleted. + +**Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record` is the key source and `en satisfies Record` locks bilingual balance. + +**Zero-cordis atoms (ui-primitives) take copy as props**: `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). + +**The non-translation boundary (deliberate decisions, not debt):** + +- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim. +- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages. +- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately). +- **Boot copy stays hardcoded** (AppRoot renders before the locale service exists). + +**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. + +**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario deliberately bypasses the helper to cover the zh default. + +The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle). + +## Alternatives considered + +- **Keep labels as strings and re-register on switch** (the early adopters' original shape): boot already registers once per package, and `locale/change` listeners re-registering amplifies into a storm; ledger version churn also busts every version-keyed projection cache. Thunks move the refresh cost to read points that already follow the revision. +- **A locale context/injection channel for ui-primitives**: breaks the zero-cordis boundary (atoms would depend on the runtime) and drags unlocalized consumers (ui-trajectory) along. Props let each consumer decide independently. +- **Error strings in the dictionaries**: the error surface is a debugging surface — verbatim English is what gets searched and compared in reports; wire pass-throughs are untranslatable anyway, and half-translation manufactures mixed-language text. +- **`toLocaleString()`/Intl for dates**: follows the browser/OS language, not the app locale, guaranteeing mixed text after a switch; the dictionary templates are tiny and isomorphic to the message clock. +- **Blank rows matching search (against localized or stored titles)**: either choice yields "visible but unfindable" in one language; placeholder rows carry no information, so whole-row exclusion is the stable semantic. + +## Consequences + +- A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue. +- Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically. +- ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo. +- Pinning e2e to English means the zh default is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md new file mode 100644 index 0000000000..062d982e3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -0,0 +1,45 @@ +# Agent Note: client 文案全量接入 typed locale 席位与不翻译边界 + +Status: implemented + +[English](2026-07-30-client-locale-full-rollout.md) | 中文 + +## Problem + +typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制与边界决定:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新;zero-cordis 的 ui-primitives 原子组件如何拿到文案;哪些字符串**刻意不**本地化——没有记录的边界会诱使后来者"补完"翻译。 + +## Decision + +**注册期文本走 label thunk。** ui-slots 的 list 注册项 `label` 接受 `SlotLabel = string | (() => string)`;owner 投影 ledger 行时必须经 `resolveSlotLabel` 解析(不裸读 `options.label`),并让读取点跟随 locale revision(outlet 自身订阅 revision;ledger 外的投影如 ui-settings 导航把 revision 并进缓存键、订阅双源)。thunk 每次读取时求值,语言切换零 ledger churn——没有重注册、version 不动,`locale/change` 重注册接线全部删除。 + +**组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 + +**zero-cordis 原子组件(ui-primitives)文案 props 化**:`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 + +**不翻译边界(刻意决定,不是欠账):** + +- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError message、wire 透出的 `error.message (code)` 原样呈现。 +- **设计字面量不进字典**:tool 行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、StatsLine 全部指标——中英界面显示一致。 +- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。 +- **boot 文案保持硬编码**(AppRoot 渲染早于 locale 服务可用)。 + +**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。 + +**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一 `newEnglishPage`(boot 前钉 `dsh.locale=en`),built-boot snapshot 同样钉 en——golden 对语言迁移免疫;settings 语言切换用例刻意绕开该 helper 覆盖 zh 默认态。 + +[settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中"apply 层订阅 `locale/change` 重注册刷新 label"的机制已被本决定取代(thunk + revision 生命周期)。 + +## Alternatives considered + +- **label 保持 string、语言切换时重注册**(先行包的旧形态):boot 每包一次注册已很重,`locale/change` 监听者重注册会放大成风暴;ledger version 抖动还会击穿一切按 version 缓存的投影。thunk 把刷新成本移到读取点,读取点本来就跟随 revision。 +- **给 ui-primitives 造 locale context/注入通道**:破坏 zero-cordis 边界(原子组件从此依赖运行时),且强迫未本地化消费者(ui-trajectory)陪跑。props 化让每个消费者独立决定。 +- **错误串进字典**:错误面是排障面,英文原样最利于搜索与上报比对;且 wire 透出串本就不可译,半译反而制造混合语言。 +- **日期用 `toLocaleString()`/Intl**:跟随浏览器/OS 语言而非应用语言,切换后必然产生混合文本;字典模板量小且与消息时钟同构。 +- **blank 行参与搜索(匹配本地化标题或存储标题)**:任一选择都在某个语言下"看得见搜不到";占位行本无信息量,整体排除语义最稳。 + +## Consequences + +- 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。 +- 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 拿到函数);类型上 `SlotLabel` 已挡住多数误用。 +- ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费点传 label**——未迁移包(ui-trajectory 的 JsonTree)显示英文默认恰好符合其整包英文现状。 +- e2e 英文钉死意味着 zh 默认态主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index ad6c575d1e..96dc47f9f7 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-client-settings-locale-theme.md: 87077b3fd3f0bd8a3375a71aebf947cbd9961799 -2026-07-25-client-settings-locale-theme.zh.md: a64a4afdf6565a527a25136694aa79305eeabb3c +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +2026-07-25-client-settings-locale-theme.md: c86d6ac053f7bb87ce758613a5f3a0a34951e428 +2026-07-25-client-settings-locale-theme.zh.md: 05edbb3c550828832a390e3cf4fad3262b5be196 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index 87077b3fd3..c86d6ac053 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -55,7 +55,7 @@ root └─ models (order 10) ui-models 注册 ``` -Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. +Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, one-call disposal; localized labels ride the label thunk from the [full-rollout note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md), not `refresh()`) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. ### Future work: promote slot declarations to first-class injectable waits diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index a64a4afdf6..05edbb3c55 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -55,7 +55,7 @@ root └─ models (order 10) ui-models 注册 ``` -section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 +section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、一键 dispose;本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk,不再 `refresh()`),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 ### Future work:坑位声明升格为可 inject 的一等等待物 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 69d5d5cfae..0bac8a2eb8 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -60,6 +60,9 @@ let unmount: (() => void) | undefined beforeEach(() => { localStorage.clear() + // English pinned before boot: role/text locators stay deterministic across + // localized component migrations (the newEnglishPage e2e convention). + localStorage.setItem('dsh.locale', 'en') document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 8105519338..ce97cfb2a1 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -98,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE) const sidebarBefore = await sidebarTrack(page) @@ -118,18 +118,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await appFrame(page).waitFor({ timeout: 30_000 }) await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() await original.click() await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) const ungrouped = page.getByText('Ungrouped', { exact: true }) const ungroupedRow = ungrouped.locator('..').locator('..') diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 3af8c14b58..4d798e11ed 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -66,12 +66,12 @@ describe('web e2e: message IconActions and clocks on settled history', () => { // Focus-reveal the footers (hover:hover keeps them opacity-hidden until // hover/focus-within). User has three actions; each turn's last content // assistant has copy + branch. - const copyButtons = page.getByRole('button', { name: '复制' }) + const copyButtons = page.getByRole('button', { name: 'Copy' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) await copyButtons.first().focus() - await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 }) + await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) .toBeGreaterThanOrEqual(2) - await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1) + await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(1) }, 60_000) it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { @@ -81,7 +81,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { }).waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. - await page.getByRole('button', { name: '复制' }).first().focus() + await page.getByRole('button', { name: 'Copy' }).first().focus() const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) @@ -91,7 +91,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) // Exercise the assistant action specifically; package coverage pins the // user action separately at its own event seq. - await page.getByRole('button', { name: '在新对话中分支' }).last().click() + await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), { timeout: 15_000 }, diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index e88cfa8857..12a2c362ab 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -253,7 +253,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { } }) expect(dot.state).toBe('done') - expect(dot.label).toBe('已完成') + expect(dot.label).toBe('Done') expect(dot.beforePrompt).toBe(true) expect(dot.insideCard).toBe(true) expect(dot.leftOfPrompt).toBe(true) @@ -270,7 +270,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) await card.locator('[class*="_copyButton_"]').first().click() await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 }) - .toBe('复制成功') + .toBe('Copied') expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK') }, 60_000) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 1c2ca271aa..42e44c14ca 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -81,7 +81,7 @@ describe('web e2e: queue row actions', () => { await input.fill(text) await input.press('Enter') } - const queueHeader = page.getByRole('button', { name: '2 条排队消息' }) + const queueHeader = page.getByRole('button', { name: '2 queued messages' }) await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 }) .toBe('false') const collapsedSnapshot = await captureStableAria( @@ -92,21 +92,21 @@ describe('web e2e: queue row actions', () => { await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE) await queueHeader.click() await expect.poll( - () => page.getByRole('button', { name: '删除排队消息' }).count(), + () => page.getByRole('button', { name: 'Remove queued message' }).count(), { timeout: 10_000 }, ).toBe(2) const editRow = page.getByText(EDIT, { exact: true }).locator('..') - await editRow.getByRole('button', { name: '编辑排队消息' }).click() - const editor = page.getByRole('textbox', { name: '编辑排队消息' }) + await editRow.getByRole('button', { name: 'Edit queued message' }).click() + const editor = page.getByRole('textbox', { name: 'Edit queued message' }) await editor.fill(EDITED) const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE) - await page.getByRole('button', { name: '保存排队消息' }).click() + await page.getByRole('button', { name: 'Save queued message' }).click() await page.getByText(EDITED, { exact: true }).waitFor() const removeRow = page.getByText(REMOVE, { exact: true }).locator('..') - await removeRow.getByRole('button', { name: '删除排队消息' }).click() + await removeRow.getByRole('button', { name: 'Remove queued message' }).click() await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) @@ -116,7 +116,7 @@ describe('web e2e: queue row actions', () => { expect(tripwire.warnings).toEqual([]) const editedRow = page.getByText(EDITED, { exact: true }).locator('..') - await editedRow.getByRole('button', { name: '删除排队消息' }).click() + await editedRow.getByRole('button', { name: 'Remove queued message' }).click() await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0) await page.getByRole('button', { name: 'Stop generating' }).click() await settled diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 4d7825963a..3437c41e18 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -147,7 +147,7 @@ describe('web e2e: seeded history renders through cold resume', () => { }], }, })) - await page.getByRole('button', { name: '上下文注入' }).waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Context injection' }).waitFor({ timeout: 10_000 }) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { @@ -165,7 +165,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) - const disclosure = page.getByRole('button', { name: '上下文注入' }) + const disclosure = page.getByRole('button', { name: 'Context injection' }) expect(await disclosure.getAttribute('aria-expanded')).toBe('false') const collapsedIcon = disclosure.locator('svg').first() const collapsedIconBox = await collapsedIcon.boundingBox() diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 0282a16f80..afc722db14 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - 'button "Think The user wants me to write a single `run_code` program that:"': - img @@ -27,9 +27,9 @@ - img - text: Think The program ran successfully. Let me now reply DONE as instructed. - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5b51e47cf4..297915e52f 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to:": - img @@ -26,7 +26,7 @@ - button [expanded]: - img - text: Mount temporary Plugin typescript -- button "复制" +- button "Copy" - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."': - img @@ -41,9 +41,9 @@ - img - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop. - paragraph: CORDIS_UI_DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 49c7958292..089a9e8efe 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -5,28 +5,28 @@ - tab "Chat" [selected] - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img - text: Think The user wants me to run a simple bash command and reply with "DONE". - img -- text: Bash Echo the test string 已完成 workspace echo WEB_E2E_OK -- button "复制" +- text: Bash Echo the test string Done workspace echo WEB_E2E_OK +- button "Copy" - text: WEB_E2E_OK - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": - img - img - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 65abda0dba..70424a4c8c 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -34,6 +34,6 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 详情 -- button "关闭详情" -- text: 点击消息流中的工具行查看详情 +- text: Details +- button "Close details" +- text: Click a tool row in the message flow to view its details diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 45e3514fa4..52e43a54f7 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -5,20 +5,20 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to reply with a single word. Let me comply.": - img - img - text: Think The user wants me to reply with a single word. Let me comply. - paragraph: LIGHTHOUSE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4323c94285..eab492b96e 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -5,17 +5,17 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - paragraph: partial -- text: 已停止 -- button "复制": +- text: Stopped +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1d78e91c73..b214ad80d5 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - textbox "Message the agent" - button "Add attachment": diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6a9c808342..572d77b22e 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -5,20 +5,20 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..098fa20807 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -4,13 +4,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" -- button "复制": +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" +- button "Copy": - img -- tooltip "复制" -- button "在新对话中分支": +- tooltip "Copy" +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img @@ -27,11 +27,11 @@ - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- text: {{clock}} +- text: 7/25 {{clock}} - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md index 298bf764b6..464e48628e 100644 --- a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md @@ -1,3 +1,3 @@ -- text: 已完成 {{workspace}} echo NAVIGATION_OK -- button "复制" +- text: Done {{workspace}} echo NAVIGATION_OK +- button "Copy" - text: NAVIGATION_OK diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index dd62e265ee..0aa340fa77 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -6,11 +6,11 @@ - tab "Trajectory" - img - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."': - img @@ -29,9 +29,9 @@ - img - text: "Think The plan was approved. The user's last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop." - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..20603ed2f2 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img @@ -24,9 +24,9 @@ - img - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index cdbf6fc64b..0b92df9a16 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -5,14 +5,14 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - paragraph: partial -- button "2 条排队消息" +- button "2 queued messages" - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index cf287b5006..5ced1f6f4e 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -5,26 +5,26 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - paragraph: partial -- button "2 条排队消息" [disabled] [expanded] +- button "2 queued messages" [disabled] [expanded] - list: - listitem: - text: Queue item to remove - - button "编辑排队消息": + - button "Edit queued message": - img - - button "删除排队消息": + - button "Remove queued message": - img - listitem: - - textbox "编辑排队消息": Edited queue item - - button "保存排队消息": + - textbox "Edit queued message": Edited queue item + - button "Save queued message": - img - - button "取消编辑": + - button "Cancel editing": - img - textbox "Message the agent" - button "Add attachment": diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 919617bdab..343ecc0fe2 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -5,19 +5,19 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - paragraph: partial - list: - listitem: - text: Edited queue item - - button "编辑排队消息": + - button "Edit queued message": - img - - button "删除排队消息": + - button "Remove queued message": - img - textbox "Message the agent" - button "Add attachment": diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index efc43a272e..3204da2d59 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -4,12 +4,12 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" -- button "复制": +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img @@ -26,15 +26,15 @@ - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- text: {{clock}} -- button "上下文注入": +- text: 7/25 {{clock}} +- button "Context injection": - img - img - - text: 上下文注入 + - text: Context injection - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 28127fd73b..9cac976f9e 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5efbcf385d..aa51d2c489 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img @@ -18,15 +18,15 @@ - button: - img - img -- text: "Ask question 1/1 answered 插话 Interjection: include the word BANANA in your final reply." +- text: "Ask question 1/1 answered Interjection Interjection: include the word BANANA in your final reply." - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img - text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer. - paragraph: Great, let's move forward. BANANA! -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index b36e001dd1..3cbae0b9ee 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -121,9 +121,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // exists yet and no interjection bubble renders — the composer still // blocks, alone. The DOM is stable here (no further SSE frames can // arrive until the question is answered), making this state capturable. - expect(await page.getByText('插话').count()).toBe(0) + expect(await page.getByText('Interjection', { exact: true }).count()).toBe(0) expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) - expect(await page.getByRole('button', { name: '编辑排队消息' }).count()).toBe(0) + expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE) } @@ -157,7 +157,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // Visible: the badged interjection bubble plus the reply that obeys it // (steer text + final reply each contain the marker word). - await expect.poll(() => page.getByText('插话').count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Interjection', { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) expect(await page.locator('[data-question-key]').count()).toBe(0) diff --git a/docs/module-graph.md b/docs/module-graph.md index 8e8b3130ab..2949cb778d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -316,10 +316,6 @@ flowchart TD pkg_client_ui_settings --> pkg_invariants pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_helper --> pkg_brand @@ -389,20 +385,15 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -479,10 +470,16 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -560,6 +557,7 @@ flowchart TD pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime pkg_client_ui_command --> pkg_client_ui_conversation pkg_client_ui_command --> pkg_client_ui_primitives @@ -573,6 +571,10 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -651,6 +653,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_user_approval pkg_client_ui_goal --> pkg_client_connection + pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives @@ -925,6 +928,7 @@ flowchart TD pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale pkg_client_ui_plan --> pkg_client_runtime pkg_client_ui_plan --> pkg_client_ui_conversation pkg_client_ui_plan --> pkg_client_ui_slots @@ -1056,7 +1060,6 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | @@ -1077,9 +1080,8 @@ flowchart TD | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | @@ -1102,7 +1104,8 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | @@ -1122,9 +1125,10 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1141,7 +1145,7 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | @@ -1183,7 +1187,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 987039b385..7100e1595e 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -37,6 +37,7 @@ export { FixtureSession, TestSessions } from './sessions.ts' export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' +export { makeTranslate } from './translate.ts' /** Erased register face for the internal root call (the public declare seam holds the typing). */ type ErasedRegister = (options: object, component: unknown) => () => void diff --git a/packages/client/test-runtime/src/translate.ts b/packages/client/test-runtime/src/translate.ts new file mode 100644 index 0000000000..65c06d5cee --- /dev/null +++ b/packages/client/test-runtime/src/translate.ts @@ -0,0 +1,32 @@ +/** + * Test double of the locale lookup chain: a translate stub over plain + * dictionaries, mirroring LocaleService's resolution order (first dictionary + * that owns the key wins, then the key itself stays visible) and its + * `{name}` template interpolation. Specs stub the framework-injected `t` + * seat with `makeTranslate(zh, commonZh)` instead of re-implementing the + * chain per suite. + */ + +/** + * Build a translate stub resolving through `dicts` in order (namespace + * first, then the shared common vocabulary), falling back to the key. + * @param dicts - dictionaries consulted in order. + * @returns the translate function (assignable to any `XxxProps['t']` seat). + */ +export function makeTranslate( + ...dicts: readonly Record[] +): (key: string, params?: Record) => string { + return (key, params) => { + let template = key + for (const dict of dicts) { + const hit = dict[key] + if (hit !== undefined) { + template = hit + break + } + } + if (!params) return template + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match) + } +} diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index c34f34dc18..8144aea6c8 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -25,6 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-slash", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -40,6 +41,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -51,7 +53,9 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx index ec0bbdd2bb..7fe5edcb86 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.tsx +++ b/packages/client/ui-command/src/client/PopupSelectView.tsx @@ -13,6 +13,7 @@ import { useEffect, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { filterOptions } from './popup.ts' import type { PopupSelectController } from './popup.ts' import css from './PopupSelectView.module.css' @@ -26,12 +27,15 @@ export interface PopupSelectInjected { popup: PopupSelectController } +/** Full shell props: injected face + the locale seat. */ +export type PopupSelectViewProps = PopupSelectInjected & PropsLocale<'command'> + /** * Render the popupSelect shell overlay entry. - * @param props - injected face: the session's shell controller. + * @param props - injected face: the session's shell controller; `t` rides the standard locale seat. * @returns the select card while open; null while closed. */ -export function PopupSelectView({ popup }: PopupSelectInjected) { +export function PopupSelectView({ popup, t }: PopupSelectViewProps) { const state = useSyncExternalStore( fn => popup.state.subscribe(fn), () => popup.state.getSnapshot(), @@ -103,15 +107,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { ref={cardRef} className={css.card} style={{ maxHeight }} - aria-label={`/${String(state.command)} options`} + aria-label={t('overlay.aria', { command: String(state.command) })} onKeyDown={onKeyDown} > { popup.setSearch(ev.currentTarget.value) }} @@ -120,15 +124,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
{state.error} {state.status === 'failed' && ( - + )}
)} - {state.status === 'pending' &&
Loading options…
} - {state.submitting &&
Applying…
} - {state.status === 'ready' && rows.length === 0 &&
No options
} + {state.status === 'pending' &&
{t('status.loading')}
} + {state.submitting &&
{t('status.applying')}
} + {state.status === 'ready' && rows.length === 0 &&
{t('status.empty')}
} {state.status === 'ready' && ( -
+
{rows.map((option, index) => (
ctx.locale.register(NS, { zh, en }), 'ui-command: dictionaries') ctx.plugin(CommandService) // Conditional mount, same seam as ui-slash's MenuView registration: // 'conversation.input.overlay' is declared by the conversation composer @@ -51,6 +66,7 @@ export function apply(ctx: ClientContext): void { name: 'conversation.input.overlay', id: 'command-popup', order: 1, + locale: NS, inject: (sessionId): PopupSelectInjected => { const actx = sessions.scope(sessionId) if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`) diff --git a/packages/client/ui-command/src/client/locales.ts b/packages/client/ui-command/src/client/locales.ts new file mode 100644 index 0000000000..63c5862cf2 --- /dev/null +++ b/packages/client/ui-command/src/client/locales.ts @@ -0,0 +1,26 @@ +/** `command` namespace dictionaries (the popupSelect shell's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'search.placeholder': '搜索…', + 'search.aria': '筛选选项', + 'status.loading': '正在加载选项…', + 'status.applying': '正在应用…', + 'status.empty': '无选项', + 'overlay.aria': '/{command} 选项', + 'listbox.aria': '/{command} 匹配项', +} satisfies Record + +/** The command namespace key union. */ +export type CommandKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'search.placeholder': 'Search…', + 'search.aria': 'Filter options', + 'status.loading': 'Loading options…', + 'status.applying': 'Applying…', + 'status.empty': 'No options', + 'overlay.aria': '/{command} options', + 'listbox.aria': '/{command} matches', +} satisfies Record diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index 03c0df2d50..bb49cdf4d1 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -13,6 +13,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import type { CommandServiceContract } from '../src/client/contract.ts' import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, CommandService, inject } from '../src/client/index.ts' const sid = (k: string): SessionId => k as SessionId @@ -41,6 +42,7 @@ async function bench() { }, }) ctx.provide('conversation', {}) + ctx.provide('locale', new LocaleService(ctx)) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const mint = (key: string) => { @@ -53,7 +55,7 @@ async function bench() { describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'connection']) + expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale']) }) it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => { diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index 2cd9891478..d8fadaae7a 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -14,6 +14,12 @@ import type { SelectOption } from '../src/client/contract.ts' import type { PopupSpec, TokenSegment } from '../src/client/popup.ts' import { PopupSelectController } from '../src/client/popup.ts' import { PopupSelectView } from '../src/client/PopupSelectView.tsx' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { zh } from '../src/client/locales.ts' + +// The framework-injected t seat, stubbed over the zh dictionaries (the default locale). +const t: Parameters[0]['t'] = makeTranslate(zh, commonZh) // jsdom has no scrollIntoView; the view calls it on the highlighted row. const scrollIntoView = vi.fn() @@ -47,12 +53,12 @@ async function mountOpen(overrides: Partial> = {}, consumeResu const consume = vi.fn((_segment: TokenSegment) => consumeResult) const focusComposer = vi.fn() const popup = new PopupSelectController({ consume, focusComposer }) - const view = render() + const view = render() await act(async () => { popup.open('theme', spec(overrides), 'ctx-A', SEGMENT) await Promise.resolve() }) - return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: 'Filter options' }) } + return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: '筛选选项' }) } } function rowLabels(): string[] { @@ -62,13 +68,13 @@ function rowLabels(): string[] { describe('PopupSelectView', () => { it('renders null while closed, opens with focus in the search input', async () => { const popup = new PopupSelectController({ consume: () => true, focusComposer: () => {} }) - const view = render() + const view = render() expect(view.container.childElementCount).toBe(0) await act(async () => { popup.open('theme', spec(), 'ctx-A', SEGMENT) await Promise.resolve() }) - const search = screen.getByRole('textbox', { name: 'Filter options' }) + const search = screen.getByRole('textbox', { name: '筛选选项' }) expect(document.activeElement).toBe(search) expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia']) }) @@ -82,7 +88,7 @@ describe('PopupSelectView', () => { expect(options).toHaveBeenCalledTimes(1) act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) }) expect(screen.queryByRole('option')).toBeNull() - expect(screen.queryByText('No options')).not.toBeNull() + expect(screen.queryByText('无选项')).not.toBeNull() }) it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => { @@ -110,13 +116,13 @@ describe('PopupSelectView', () => { it('caps the card height at the design maximum when the composer sits low enough', async () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect) await mountOpen() - expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('320px') + expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('320px') }) it('clamps the card height to the space above the composer minus the safe margin', async () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect) await mountOpen() - expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('188px') + expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px') }) it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => { @@ -148,7 +154,7 @@ describe('PopupSelectView', () => { const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve })) const { search, consume } = await mountOpen({ onSelect }) await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) }) - expect(screen.queryByText('Applying…')).not.toBeNull() + expect(screen.queryByText('正在应用…')).not.toBeNull() expect((search as HTMLInputElement).readOnly).toBe(true) await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) @@ -162,7 +168,7 @@ describe('PopupSelectView', () => { expect(consume).toHaveBeenCalledTimes(1) }) - it('a failed options load shows the error with a Retry button that reloads', async () => { + it('a failed options load shows the error with a retry button that reloads', async () => { let attempts = 0 await mountOpen({ options: () => { @@ -172,7 +178,7 @@ describe('PopupSelectView', () => { }) expect(screen.getByRole('alert').textContent).toContain('directory down') await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + fireEvent.click(screen.getByRole('button', { name: '重试' })) await Promise.resolve() }) expect(attempts).toBe(2) @@ -183,7 +189,7 @@ describe('PopupSelectView', () => { const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) }) await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) }) expect(screen.getByRole('alert').textContent).toContain('host rejected') - expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + expect(screen.queryByRole('button', { name: '重试' })).toBeNull() expect(consume).not.toHaveBeenCalled() expect(screen.getAllByRole('option').length).toBe(3) }) diff --git a/packages/client/ui-command/tsconfig.json b/packages/client/ui-command/tsconfig.json index b95692eda1..f83486aa36 100644 --- a/packages/client/ui-command/tsconfig.json +++ b/packages/client/ui-command/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../connection" }, + { + "path": "../locale" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index c124521092..cc9f5a575b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: bfb56dc52406e1377cd84c87866a644ade10293a -README.zh.md: e09bdcc4bebd8176a3061b221e07ae73a7a8941c +README.md: b4b1e5653705c76bac3e0227e6df77143a11cbbe +README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bfb56dc524..b4b1e56537 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -24,7 +24,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e09bdcc4be..74e0f3dc0e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -24,7 +24,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9c2008f092..da8c9b1910 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,6 +1,6 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -28,6 +28,14 @@ import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' +import { en, NS, zh, type ConversationKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The conversation surfaces' copy (skeleton, chat view, toolviews, docks). */ + conversation: ConversationKey + } +} /** Services required by the conversation plugin. */ export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] @@ -68,32 +76,12 @@ export function apply(ctx: Context): void { const layout = ctx.layout const slots = ctx.slots - // Command hint locale: friendly placeholder text for claimed commands. The - // claimed /plan hint and the plan-mode textarea placeholder share one - // string: both describe the same next action. - const HINT_NS = 'command.hint' - const PLAN_HINT_ZH = '描述你的任务以生成计划' - const PLAN_HINT_EN = 'describe your task to generate plan' - ctx.effect(() => { - const disposers = [ - ctx.locale.register(HINT_NS, 'zh', { - plan: PLAN_HINT_ZH, - goal: '输入目标,智能体将持续执行', - 'goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', - 'placeholder.plan': PLAN_HINT_ZH, - 'placeholder.default': '给智能体发消息', - }), - ctx.locale.register(HINT_NS, 'en', { - plan: PLAN_HINT_EN, - goal: 'describe the objective for a long-running task', - 'goal.active': 'goal active — edit / pause / resume / clear', - 'placeholder.plan': PLAN_HINT_EN, - 'placeholder.default': 'Message the agent', - }), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-conversation: command hint dictionaries') - const translateHint = ctx.locale.bind(HINT_NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') + + // Registration-time text (the view tab label) reads through the bound + // translate as a thunk, so it follows the active locale without + // re-registration; components read the standard `t` seat instead. + const t = ctx.locale.bind(NS) // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() @@ -103,7 +91,7 @@ export function apply(ctx: Context): void { for (const entry of slots.entries('conversation.view')) { /* v8 ignore next -- unreachable: list registration validates id at load. */ if (entry.options.id === undefined) continue - tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id }) + tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id }) } return tabs } @@ -132,6 +120,7 @@ export function apply(ctx: Context): void { // frame while strict session slots fill only their session-bound regions. slots.register({ name: 'conversation', + locale: NS, children: { 'conversation.session': { kind: 'single', scope: 'session' }, 'conversation.composer': { kind: 'chain', scope: 'session' }, @@ -163,6 +152,7 @@ export function apply(ctx: Context): void { // the resident parent keeps Hero and composer layout identity stable. slots.register({ name: 'conversation.session', + locale: NS, children: { 'conversation.view': { kind: 'list', scope: 'session' } }, store: chatStore, inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => ({ @@ -185,6 +175,7 @@ export function apply(ctx: Context): void { // observableHook caching and hook order stay stable across transitions). slots.register({ name: 'conversation.composer.bar', + locale: NS, // The two named control seats in the bar's tool row (plan beside the // access control, model right); empty until their owning plugins // register (B ruling). @@ -198,7 +189,6 @@ export function apply(ctx: Context): void { keyboard: undefined, stop: undefined, command: undefined, - translateHint, hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON }, } } @@ -216,7 +206,6 @@ export function apply(ctx: Context): void { const result = await session.command(line) return result.ok && result.value.matched }, - translateHint, hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, @@ -230,7 +219,7 @@ export function apply(ctx: Context): void { // pending — a question is a conversation the model is waiting on, while an // approval only blocks one tool call; answering the question first cannot // strand the approval (it re-elects the moment the question resolves). - slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel) + slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel) // The chat view: first entry of the ring this package just declared. // Declaring the keyed toolview hole here is claiming it: ChatView is the @@ -241,7 +230,8 @@ export function apply(ctx: Context): void { name: 'conversation.view', id: 'chat', order: 0, - label: 'Chat', + label: () => t('view.chat'), + locale: NS, children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' }, 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, @@ -303,6 +293,7 @@ export function apply(ctx: Context): void { slots.register({ name: 'details', + locale: NS, store: chatStore, inject: (): DetailsInjected => ({ closeDetails: () => { layout.closeDetails() }, diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 34243fddd5..b328404518 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -8,11 +8,12 @@ // ends (`time` is omitted for mid-turn narration); Think / tool-head-only // nodes stay chrome-free. -import { memo } from 'react' +import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -20,7 +21,7 @@ import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { blocks: readonly AssistantBlock[] streaming: boolean - /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */ + /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined /** Unix epoch ms for the IconActions clock; omitted while streaming or when * the parent withholds chrome (mid-turn content assistants). */ @@ -29,6 +30,8 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through the turn containing this finalized message. */ onFork?: ((seq: number) => void) | undefined + /** The owning view's locale seat, passed down as a plain prop. */ + t: ChatViewSlotProps['t'] } function firstLine(text: string): string { @@ -51,9 +54,10 @@ function hasContentText(blocks: readonly AssistantBlock[]): boolean { } /** Reasoning block as the Think variant summary row (figma 39:28304). */ -function ThinkRow({ text, running }: { text: string; running: boolean }) { +function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) { return ( } title="Think" @@ -66,8 +70,11 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, + blocks, streaming, interrupted, time, seq, onFork, t, }: AssistantMarkdownProps) { + // Stable per locale revision (t identity changes on switch): a fresh object + // per render would rebuild MarkdownText's component table every chunk. + const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -83,14 +90,23 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
{blocks.map((block, i) => { switch (block.kind) { - case 'text': return - case 'reasoning': return + case 'text': return ( + + ) + case 'reasoning': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null - default: return + default: return ( + t('json.truncated', { total })} + /> + ) } })} - {interrupted && 已停止} + {interrupted && {t('message.stopped')}}
{showActions && ( { onFork(seq) }} className={css.actions} + t={t} /> )}
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 088b605d55..f8536e5fe3 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -57,12 +57,13 @@ type UseConversation = SnapshotSelectorHook * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and * renders the running state exactly as a native in-flight row. */ -const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: { +const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, t }: { renderSlot: RenderToolRow node: CodeSubCall openFile: OpenFile selected: boolean cwd: string | undefined + t: ChatViewSlotProps['t'] }) { const settled = 'kind' in node const toolName = settled ? node.call?.name ?? '' : node.name @@ -73,7 +74,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
{renderSlot('conversation.chat.toolview', owner, { entryKey: toolName, - fallback: , + fallback: , })}
) @@ -85,7 +86,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select * renders its logged sub-dispatches as always-visible indented rows — * each one the same keyed-slot dispatch as a native top-level call. */ const CallRow = memo(function CallRow({ - renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, + renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, t, }: { renderSlot: RenderToolRow callId: string @@ -100,6 +101,7 @@ const CallRow = memo(function CallRow({ selectedCallId?: string | undefined /** Session workspace root for path-relative summaries. */ cwd: string | undefined + t: ChatViewSlotProps['t'] }) { const owner = useMemo(() => ({ callId, toolName, block, openFile, cwd, @@ -108,7 +110,7 @@ const CallRow = memo(function CallRow({
{renderSlot('conversation.chat.toolview', owner, { entryKey: toolName, - fallback: , + fallback: , })} {subCalls !== undefined && subCalls.length > 0 && (
@@ -120,6 +122,7 @@ const CallRow = memo(function CallRow({ openFile={openFile} selected={node.callId === selectedCallId} cwd={cwd} + t={t} /> ))}
@@ -129,7 +132,7 @@ const CallRow = memo(function CallRow({ }) /** Consecutive tool results as one step-run group (uniform 16px rhythm). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: { +const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, t }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] openFile: OpenFile @@ -139,6 +142,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec codeDispatches: ReadonlyMap /** Session workspace root for path-relative summaries. */ cwd: string | undefined + t: ChatViewSlotProps['t'] }) { return (
@@ -154,6 +158,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} cwd={cwd} + t={t} /> ))}
@@ -163,16 +168,17 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec /** One command lifecycle row: keyed dispatch on the command name with the * generic card as the render-site fallback (zero registration required). A * run-less cross-window node has no name and always lands on the fallback. */ -const CommandRow = memo(function CommandRow({ renderSlot, node }: { +const CommandRow = memo(function CommandRow({ renderSlot, node, t }: { renderSlot: RenderToolRow node: CommandNode + t: ChatViewSlotProps['t'] }) { const owner = useMemo(() => ({ node }), [node]) return (
{renderSlot('conversation.chat.commandview', owner, { entryKey: node.name ?? '', - fallback: , + fallback: , })}
) @@ -214,23 +220,24 @@ function TurnDots() { /** The streaming partial, isolated so chunk batches re-render only this tail. * onGrow lets the scroll owner follow content the parent never re-renders for. */ -function StreamingTail({ useSession, onGrow }: { +function StreamingTail({ useSession, onGrow, t }: { useSession: UseConversation onGrow: () => void + t: ChatViewSlotProps['t'] }) { const partial = useSession(s => s.partial) useLayoutEffect(() => { onGrow() }) if (partial === null) return null - return + return } /** * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) { +export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt, t }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -238,7 +245,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) const openState = useSession(s => s.openState) - const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) + const openError = useSession(s => s.openError) const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) @@ -368,6 +375,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} cwd={cwd} + t={t} /> ) } @@ -382,32 +390,37 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} onFork={forkAt} + t={t} /> ) } if (node.kind === 'command') { - return + return } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null - return + return } return (
- {openState === 'loading' &&
载入历史…
} - {openState === 'error' &&
历史加载失败:{openErrorMessage}
} + {openState === 'loading' &&
{t('chat.loadingHistory')}
} + {openState === 'error' && openError !== null && ( +
+ {t('chat.loadError', { message: openError.message, code: openError.code })} +
+ )} {hasMore && (
)} {items.map(renderItem)} - + {runningCalls.length > 0 && (
{runningCalls.map(call => ( @@ -422,6 +435,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio subCalls={codeDispatches.get(call.callId)} selectedCallId={selectedCallId} cwd={cwd} + t={t} /> ))}
@@ -438,7 +452,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio - - {edit === true && ( - - diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 2eb8e313ae..bb6429470f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,6 +10,7 @@ import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' @@ -18,6 +19,8 @@ export interface MessageItemProps { node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode /** Fork the session through the turn containing this message (user-bubble branch action). */ onFork?: (seq: number) => void + /** The owning view's locale seat, passed down as a plain prop. */ + t: ChatViewSlotProps['t'] } function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { @@ -63,7 +66,8 @@ function projectUserText(text: string): ReactNode { return <>{parts} } -export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) { +export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) { + const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { case 'user': { const { text, rest } = contentText(node.content) @@ -71,7 +75,7 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
{projectUserText(text)} - {rest.map((block, i) => )} + {rest.map((block, i) => )}
{ onFork(node.seq) }} className={css.actions} + t={t} />
) @@ -89,21 +94,21 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt return (
- 插话 + {t('message.steering')} {projectUserText(text)} - {rest.map((block, i) => )} + {rest.map((block, i) => )}
) } case 'context': return ( - + ) default: return (
- +
) } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 365000ebb9..0be424a96b 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -10,12 +10,15 @@ import { useState, type MouseEvent, type ReactNode } from 'react' import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' -import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' import { DisclosureRow } from './DisclosureRow.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { + /** The render site's conversation locale seat (terminal/code body copy). */ + t: TranslateNS<'conversation'> variant: ToolRowVariant /** Wire tool name for tool-owned styling layered over the generic variant. */ toolName?: string | undefined @@ -56,6 +59,7 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { } export function ToolRow({ + t, variant, toolName, icon, @@ -125,9 +129,16 @@ export function ToolRow({
{terminalBody.description}
)} {terminalBody !== null - ? + ? ( + + ) : variant === 'code' - ? + ? :
{text}
}
diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-conversation/src/client/chat/message-chrome.ts index b005b8404b..67b625e71d 100644 --- a/packages/client/ui-conversation/src/client/chat/message-chrome.ts +++ b/packages/client/ui-conversation/src/client/chat/message-chrome.ts @@ -1,6 +1,11 @@ // Shared chrome helpers for user/assistant IconActions rows: clipboard write // and the compact date+clock label from a session-event epoch. +import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' + +/** The date-template share of the conversation dictionary the clock consumes. */ +export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'> + /** * Best-effort clipboard write; rejections stay swallowed (no success chrome). * @param text - Plain text to place on the clipboard. @@ -67,14 +72,16 @@ export function msUntilNextLocalMidnight(ms: number): number { } /** - * Compact local timestamp for message IconActions. - * Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`; - * other years → `YYYY年M月D日 HH:mm`. + * Compact local timestamp for message IconActions. Same calendar day → + * `HH:mm`; earlier this year → the `clock.md` date template + clock; other + * years → the `clock.ymd` template + clock. Pure: the date templates arrive + * through the caller's locale seat. * @param time - Unix epoch ms from the source session event. + * @param t - translate seat supplying the `clock.md` / `clock.ymd` templates. * @param now - Reference instant for the day/year cut (defaults to wall clock). * @returns Date-aware clock string (24-hour, zero-padded time). */ -export function formatMessageClock(time: number, now: number = Date.now()): string { +export function formatMessageClock(time: number, t: ClockTranslate, now: number = Date.now()): string { const d = new Date(time) const n = new Date(now) const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}` @@ -85,7 +92,7 @@ export function formatMessageClock(time: number, now: number = Date.now()): stri ) { return clock } - const md = `${d.getMonth() + 1}月${d.getDate()}日` - if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}` - return `${d.getFullYear()}年${md} ${clock}` + const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() } + const md = d.getFullYear() === n.getFullYear() ? t('clock.md', params) : t('clock.ymd', params) + return `${md} ${clock}` } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index dca8cbc567..bb9c540fbb 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,7 +1,7 @@ /** Conversation slot declarations and their composed component props. */ import type { ReactNode, RefObject } from 'react' import type { - InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, + InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -282,8 +282,6 @@ export interface ComposerBarInjected { * Resolves admission: false = rejected/unmatched/transport failure. */ command: ((line: string) => Promise) | undefined - /** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */ - translateHint: (key: string) => string /** * Registrant hooks compartment: the renderer binds these to * useNotices/useLexicon (static absent sources without a session — hook @@ -306,11 +304,12 @@ export interface InputControlOwnerProps { locked: boolean } -/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */ +/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> & InjectFace + & PropsLocale<'conversation'> /** * Composer chain currency: what ConversationRoot dispatches at its @@ -325,7 +324,8 @@ export interface ComposerChainProps { /** * Full conversation-slot component props: runtime & child-render (view ring - * + composer chain/bar + input-region + hero picker slots) & store & injected shares. + * + composer chain/bar + input-region + hero picker slots) & store & injected + * shares & the locale seat. */ export type ConversationSlotProps = PropsRuntime<'conversation'> & PropsRenderSlots< @@ -336,13 +336,15 @@ export type ConversationSlotProps = | 'conversation.hero.workspace' > & ConversationInjected + & PropsLocale<'conversation'> -/** Full strict-session content props: per-session store, view ring, and callbacks. */ +/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */ export type ConversationSessionSlotProps = PropsRuntime<'conversation.session'> & PropsRenderSlots<'conversation.view'> & PropsStore & ConversationSessionInjected + & PropsLocale<'conversation'> /** The pending approval carrier the owner dispatches into the composer chain. */ export type ApprovalWait = PendingWait<'approval'> @@ -400,11 +402,13 @@ export class PendingApproval { /** * Full approval-composer props: the framework runtime share (chain currency + * session/global standard kit) plus the chain `matched` share — the entry's - * selector result, already narrowed to the approval carrier. No injected - * share: the carrier plus the domain face above carry the whole behavior - * surface; the paired command line derives from useSession in-component. + * selector result, already narrowed to the approval carrier — plus the + * standard locale seat. No injected share: the carrier plus the domain face + * above carry the whole behavior surface; the paired command line derives + * from useSession in-component. */ -export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } +export type ApprovalComposerProps = + PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'> /** * Injected share of the chat view entry: the two callbacks whose targets live @@ -423,10 +427,10 @@ export interface ChatViewInjected { forkAt: (seq: number) => void } -/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */ +/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */ export type ChatViewSlotProps = PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'> - & PropsStore & ChatViewInjected + & PropsStore & ChatViewInjected & PropsLocale<'conversation'> /** * Injected share of the details slot: the panel is otherwise a pure reader of @@ -437,8 +441,8 @@ export interface DetailsInjected { closeDetails: () => void } -/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */ -export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & DetailsInjected +/** Full details-slot component props: selection rides the shared store, call material useSession; copy the locale seat. */ +export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & DetailsInjected & PropsLocale<'conversation'> /** Owner share common to the hero / New-Session Workspace pickers. */ export interface EmptyWorkspaceOwnerProps { diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index e25d68cbbb..c2d3886910 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -8,9 +8,35 @@ * are derived once. * @module */ -import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts' +/** + * Build the TerminalBlock display copy from the conversation locale seat — + * the one place the primitive's label surface pairs with this package's + * dictionary, shared by every terminal render site (chat row, bash row, + * details panel). + * @param t - the render site's conversation locale seat. + * @returns the full label set for {@link TerminalBlockProps}'s `labels`. + */ +export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlockLabels { + return { + signal: signal => t('terminal.signal', { signal }), + exitCode: code => t('terminal.exitCode', { code }), + running: t('terminal.running'), + failed: t('terminal.failed'), + done: t('terminal.done'), + copy: t('copy'), + copied: t('copied'), + noOutput: t('terminal.noOutput'), + collapseAria: t('terminal.collapseAria'), + collapse: t('collapse'), + expandAria: hidden => t('terminal.expandAria', { n: hidden }), + expand: hidden => t('terminal.expandRest', { n: hidden }), + } +} + /** * Output lines the chat row's expanded terminal body shows before collapsing * the middle — half the primitive's own default, which the details panel diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 56d398def3..d04f2473e1 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -11,6 +11,7 @@ export type { CallId, ChatStoreState, SelectionTarget, ViewTab, } from './contract/views.ts' export type { ToolCallBlock } from './contract/tool-call-model.ts' +export type { ConversationKey } from './locales.ts' export type { ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts new file mode 100644 index 0000000000..737d6dc9f0 --- /dev/null +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -0,0 +1,170 @@ +/** `conversation` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'conversation' + +// The claimed /plan hint and the plan-mode textarea placeholder share one +// string: both describe the same next action. +const PLAN_NEXT_ACTION_ZH = '描述你的任务以生成计划' +const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'view.chat': '对话', + 'hint.plan': PLAN_NEXT_ACTION_ZH, + 'hint.goal': '输入目标,智能体将持续执行', + 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', + 'placeholder.plan': PLAN_NEXT_ACTION_ZH, + 'placeholder.default': '给智能体发消息', + 'placeholder.unavailable': '会话不可用', + 'placeholder.hero': '描述你想要构建的内容', + 'placeholder.workspace': '选择一个工作区开始', + 'input.addAttachment': '添加附件', + 'input.stop': '停止生成', + 'input.send': '发送消息', + 'input.accessMode': '访问模式,当前:{name}', + 'hero.headline': '开始构建吧', + 'hero.chooseWorkspace': '选择工作区', + 'session.hierarchy': '会话层级', + 'details.title': '详情', + 'details.close': '关闭详情', + 'details.empty': '点击消息流中的工具行查看详情', + 'details.notInWindow': '该调用不在当前窗口内', + 'details.input': '输入', + 'details.output': '输出', + 'details.running': '运行中…', + 'todo.title': '任务清单', + 'todo.progress': '{done}/{total} 项任务 · {active} 项进行中', + 'todo.rowTitle': '更新任务清单', + 'todo.completed': '{done}/{total} 已完成', + 'chat.loadingHistory': '载入历史…', + 'chat.loadError': '历史加载失败:{message}({code})', + 'chat.loadOlder': '加载更早', + 'chat.toBottom': '回到底部', + 'message.extraBlock': '附加内容块', + 'message.steering': '插话', + 'message.contextInjection': '上下文注入', + 'message.unknownSurface': '未知 surface 事件:{type}', + 'message.unknownBlock': '未知内容块', + 'message.stopped': '已停止', + 'message.branch': '在新对话中分支', + 'command.running': '执行中…', + 'command.failed': '命令失败', + 'command.done': '已完成', + 'command.title': '命令', + 'approval.waiting': '等待审批', + 'approval.detail.aria': '审批详情', + 'approval.escalation': '工具 {toolName} 请求越权执行', + 'approval.reject': '拒绝', + 'approval.allowOnce': '允许一次', + 'ask.rowTitle': '提问', + 'ask.waiting': '等待回答', + 'ask.cancelled': '已取消', + 'ask.interrupted': '已中断', + 'ask.answered': '{answered}/{total} 已回答', + 'bash.running': '运行中', + 'bash.failed': '失败', + 'bash.stopped': '已停止', + 'queue.count': '{n} 条排队消息', + 'queue.edit': '编辑排队消息', + 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', + 'queue.save': '保存排队消息', + 'queue.cancelEdit': '取消编辑', + 'queue.remove': '删除排队消息', + 'queue.editFailed': '编辑失败:这条消息可能已经开始发送。', + 'queue.removeFailed': '删除失败:这条消息可能已经开始发送。', + 'terminal.signal': '信号 {signal}', + 'terminal.exitCode': '退出码 {code}', + 'terminal.running': '运行中', + 'terminal.failed': '失败', + 'terminal.done': '已完成', + 'terminal.noOutput': '无输出', + 'terminal.collapseAria': '收起输出', + 'terminal.expandAria': '展开其余 {n} 行输出', + 'terminal.expandRest': '… 其余 {n} 行', + 'json.truncated': '… 已截断,共 {total} 字符', + 'clock.md': '{m}月{d}日', + 'clock.ymd': '{y}年{m}月{d}日', +} satisfies Record + +/** The conversation namespace key union. */ +export type ConversationKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'view.chat': 'Chat', + 'hint.plan': PLAN_NEXT_ACTION_EN, + 'hint.goal': 'describe the objective for a long-running task', + 'hint.goal.active': 'goal active — edit / pause / resume / clear', + 'placeholder.plan': PLAN_NEXT_ACTION_EN, + 'placeholder.default': 'Message the agent', + 'placeholder.unavailable': 'Session unavailable', + 'placeholder.hero': 'Describe what you want to build', + 'placeholder.workspace': 'Choose a workspace to start', + 'input.addAttachment': 'Add attachment', + 'input.stop': 'Stop generating', + 'input.send': 'Send message', + 'input.accessMode': 'Access mode, current: {name}', + 'hero.headline': 'Let\'s start building', + 'hero.chooseWorkspace': 'Choose workspace', + 'session.hierarchy': 'Session hierarchy', + 'details.title': 'Details', + 'details.close': 'Close details', + 'details.empty': 'Click a tool row in the message flow to view its details', + 'details.notInWindow': 'This call is outside the current window', + 'details.input': 'Input', + 'details.output': 'Output', + 'details.running': 'Running…', + 'todo.title': 'To-dos', + 'todo.progress': '{done}/{total} tasks · {active} in progress', + 'todo.rowTitle': 'Update to-do list', + 'todo.completed': '{done}/{total} completed', + 'chat.loadingHistory': 'Loading history…', + 'chat.loadError': 'Failed to load history: {message} ({code})', + 'chat.loadOlder': 'Load earlier', + 'chat.toBottom': 'Back to bottom', + 'message.extraBlock': 'Extra content block', + 'message.steering': 'Interjection', + 'message.contextInjection': 'Context injection', + 'message.unknownSurface': 'Unknown surface event: {type}', + 'message.unknownBlock': 'Unknown content block', + 'message.stopped': 'Stopped', + 'message.branch': 'Branch into a new conversation', + 'command.running': 'Running…', + 'command.failed': 'Command failed', + 'command.done': 'Completed', + 'command.title': 'Command', + 'approval.waiting': 'Waiting for approval', + 'approval.detail.aria': 'Approval details', + 'approval.escalation': 'Tool {toolName} requests privileged execution', + 'approval.reject': 'Reject', + 'approval.allowOnce': 'Allow once', + 'ask.rowTitle': 'Ask question', + 'ask.waiting': 'waiting', + 'ask.cancelled': 'cancelled', + 'ask.interrupted': 'interrupted', + 'ask.answered': '{answered}/{total} answered', + 'bash.running': 'Running', + 'bash.failed': 'Failed', + 'bash.stopped': 'Stopped', + 'queue.count': '{n} queued messages', + 'queue.edit': 'Edit queued message', + 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', + 'queue.save': 'Save queued message', + 'queue.cancelEdit': 'Cancel editing', + 'queue.remove': 'Remove queued message', + 'queue.editFailed': 'Edit failed: this message may have already started sending.', + 'queue.removeFailed': 'Removal failed: this message may have already started sending.', + 'terminal.signal': 'signal {signal}', + 'terminal.exitCode': 'exit code {code}', + 'terminal.running': 'Running', + 'terminal.failed': 'Failed', + 'terminal.done': 'Done', + 'terminal.noOutput': 'No output', + 'terminal.collapseAria': 'Collapse output', + 'terminal.expandAria': 'Expand the remaining {n} output lines', + 'terminal.expandRest': '… {n} more lines', + 'json.truncated': '… truncated, {total} characters total', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 67de5d0796..1bc6f75e85 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -5,13 +5,14 @@ // ../contract/slots.ts beside the other input-region slots. import type { Context } from 'cordis' import { useEffect, useId, useState } from 'react' -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, IconEditOutline16, IconTrashOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId } from '../contract/queue.ts' +import { NS } from '../locales.ts' import css from './QueueDock.module.css' /** Queue operations injected by the session-scoped registration. */ @@ -20,14 +21,14 @@ export interface QueueDockInjected { notify: (level: 'info' | 'error', text: string) => void } -/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ -export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected +/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */ +export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'> /** * Queue strip: one item renders directly; multiple items default to a * collapsible count header; an empty queue renders nothing. */ -export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { +export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) { const queue = useSession(s => s.queue) const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) @@ -67,7 +68,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { if (await applyAction( editing.id, { kind: 'edit', content: [{ type: 'text', text: editing.text }] }, - '编辑失败:这条消息可能已经开始发送。', + t('queue.editFailed'), )) setEditing(null) } @@ -83,7 +84,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { disabled={interactionActive} onClick={() => { setCollapsed(value => !value) }} > - {queue.length} 条排队消息 + {t('queue.count', { n: queue.length })} {expanded ? : } @@ -97,7 +98,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { { setEditing({ id: row.id, text: event.currentTarget.value }) }} onKeyDown={(event) => { @@ -120,8 +121,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 3f9635bb48..c52ba0b6b3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -14,7 +14,7 @@ export type ConversationRootProps = ConversationSlotProps export function ConversationRoot({ sessionId, useSession, useSessions, useWorkspaces, useInput, - renderSlot, renderSlotChain, selectWorkspace, + renderSlot, renderSlotChain, selectWorkspace, t, }: ConversationRootProps) { const openState = useSession(s => s.openState) const composerPhase = useSession(s => s.composerPhase) @@ -94,6 +94,7 @@ export function ConversationRoot({ label={chipTitle} menuOpen={pickerOpen} onClick={() => { setPickerOpen(open => !open) }} + t={t} /> {renderSlot('conversation.hero.workspace', { open: pickerOpen, @@ -120,8 +121,8 @@ export function ConversationRoot({ const inputBar = renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', ...(inert - ? { disabled: true, placeholder: 'Choose a workspace to start' } - : hero ? { placeholder: 'Describe what you want to build' } : {}), + ? { disabled: true, placeholder: t('placeholder.workspace') } + : hero ? { placeholder: t('placeholder.hero') } : {}), overlay: renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), @@ -133,7 +134,7 @@ export function ConversationRoot({ const composerBar = (
{hero && } - {hero && } + {hero && } {hero && heroWorkspaceRow} {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} {inputBar} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 33b24f5245..9c1ea2fd33 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, wrapActiveBody, + renderSlot, views, bindDraftMirror, open, wrapActiveBody, t, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -65,7 +65,7 @@ export function ConversationSession({ {!hideChrome && ( <>
-