Przeglądaj źródła

Merge pull request #4186 from deepseek-harness/xtr/revert-3880-chat-behavior

fix(chat): revert Chat behavior changes from #3880
_Kerman 1 tydzień temu
rodzic
commit
11c4c52d1a
22 zmienionych plików z 168 dodań i 89 usunięć
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md
  7. 6 0
      .agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.i18n.yaml
  8. 25 0
      .agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.md
  9. 25 0
      .agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml
  11. 3 1
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md
  12. 3 1
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md
  13. 1 4
      apps/web/tests/lifecycle-chrome.e2e.ts
  14. 12 3
      apps/web/tests/turn-tail-actions.e2e.ts
  15. 2 2
      packages/client/ui-chat/README.i18n.yaml
  16. 3 1
      packages/client/ui-chat/README.md
  17. 3 1
      packages/client/ui-chat/README.zh.md
  18. 1 8
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
  19. 4 11
      packages/client/ui-chat/src/client/chat/ReasoningRow.tsx
  20. 2 21
      packages/client/ui-chat/src/client/conversation-nodes/assistant.ts
  21. 53 9
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  22. 15 17
      packages/client/ui-chat/tests/reasoning-row.client.spec.tsx

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.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-09-01-v2-embedded-assistant-streams.md
-2026-09-01-v2-embedded-assistant-streams.md: 219879600f8435a6ecc9593661dd3f463ed1158c
-2026-09-01-v2-embedded-assistant-streams.zh.md: d38c180b1f1de689620f2e0533d2b02f4a71e466
+2026-09-01-v2-embedded-assistant-streams.md: 89b494959fcc10a2c9847908adcf13d5eedcd749
+2026-09-01-v2-embedded-assistant-streams.zh.md: 63cb425740cd2243361ec00a6dadad82c38f6f0f

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md

@@ -31,7 +31,7 @@ The migration publication verifier and frozen v2 fixture validator require the e
 
 The Web follow adapter opts into these process-local frames and adds the last durable sequence observed at each start. It presents chunks as Client-only `assistant/live-chunk` updates between durable cursors, stages only a later matching settlement until the committed end, and reopens follow on a revision gap. A committed end publishes a named settlement delta that removes the attempt's transient matches, adds the durable entry, and replays only affected Conversation Contexts; an abandoned end publishes the same delta without an entry. A reconnect baseline carries the active attempt's durable start cursor and compact prefix.
 
-The Client event source passes durable settlements through unchanged. The Chat and Trajectory Assistant nodes fold `assistant/live-chunk` while an attempt is active, build settled output directly from `assistant/message`, and do not replay an `assistant/attempt` stream for presentation. Cold settled presentation therefore does not reconstruct per-token timing; other consumers may expand the durable stream when they require its exact evidence.
+The Client event source passes durable settlements through unchanged. Chat and Trajectory fold `assistant/live-chunk` while an attempt is active and build settled output directly from `assistant/message`. Chat does not reconstruct first-token timing after settlement retires the transient chunks. Trajectory reads timing from the [compact stream records](2026-09-06-embedded-stream-record-readers.md) in `assistant/message` and `assistant/attempt`, including when opening history. Neither target expands settled streams into per-delta objects for presentation; other consumers may expand the durable stream when they require its exact evidence.
 
 ### Released v1 to v2 migration
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md

@@ -31,7 +31,7 @@ Migration publication verifier 与冻结的 v2 fixture validator 要求嵌入式
 
 Web follow adapter 显式选择接收这些进程本地 frame,并为每个 start 补充当时观察到的最后一个持久序号。它把 chunk 呈现为持久 cursor 之间的 Client-only `assistant/live-chunk` update,只暂存 start 之后匹配的 settlement,并在 revision 缺口时重新打开 follow。committed end 会发布具名 settlement delta,删除该 attempt 的 transient match、加入持久 entry,并只重放受影响的 Conversation Context;abandoned end 会发布不含 entry 的同类 delta。重连 baseline 携带活跃 attempt 的持久起始 cursor 与紧凑前缀。
 
-Client event source 原样传递持久 settlement。Chat 与 Trajectory 的 Assistant node 在 attempt 活跃期间折叠 `assistant/live-chunk`,直接从 `assistant/message` 构建 settled output,并且不为展示重放 `assistant/attempt` stream。因此冷恢复的 settled presentation 不会重建逐 token timing;其他消费方需要精确证据时仍可展开持久 stream。
+Client event source 原样传递持久 settlement。Chat 与 Trajectory 在 attempt 活跃时折叠 `assistant/live-chunk`,直接从 `assistant/message` 构造 settled output。结算移除临时 chunk 后,Chat 不会重建首 token 计时。Trajectory 从 `assistant/message` 与 `assistant/attempt` 中的[紧凑流记录](2026-09-06-embedded-stream-record-readers.zh.md)读取计时,包括打开历史时。两个目标都不会为展示将已结算流展开为逐 delta 对象;其他消费方需要精确证据时仍可展开持久 stream。
 
 ### 已发布 v1 到 v2 迁移
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.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-09-06-embedded-stream-record-readers.md
-2026-09-06-embedded-stream-record-readers.md: 76e109de577d070093bb7123aa50d9a4ae7fcbe5
-2026-09-06-embedded-stream-record-readers.zh.md: 33f4b31117197c47e7f2ff1637bc13fa2cbf1a8c
+2026-09-06-embedded-stream-record-readers.md: efad86a4f940bf3b56d4b141a8fe1e3659c8dd9c
+2026-09-06-embedded-stream-record-readers.zh.md: f7131a27171163954bca8b7013dc6d90f907c552

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md

@@ -20,7 +20,7 @@ After v2 embedded streams settlement widened with the message content and Chat a
 - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment.
 - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members.
 
-Session Stats, Chat, and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Chat and Trajectory settle content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
+Session Stats and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Trajectory settles content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. Chat follows its [settled-reply timing policy](../bug-fix/2026-09-14-chat-presentation-defaults.md). The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
 
 `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md

@@ -20,7 +20,7 @@ Session 格式 v2 将每次模型尝试的紧凑流(`AssistantStreamRecord[]`
 - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。
 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。
 
-Session Stats、Chat 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Chat 与 Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
+Session Stats 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。Chat 遵循[已结算回复的计时策略](../bug-fix/2026-09-14-chat-presentation-defaults.zh.md)。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
 
 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-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/bug-fix/2026-09-14-chat-presentation-defaults.md
+2026-09-14-chat-presentation-defaults.md: 6703a14e2ad108c4b43dd50692a44c159cf3ea5a
+2026-09-14-chat-presentation-defaults.zh.md: 67ca433339ecf6e44d3f74b6c3ff9e465f802b0b

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.md

@@ -0,0 +1,25 @@
+# Agent Note: Keep Chat presentation independent of Trajectory inspection
+
+Status: implemented
+
+English | [中文](2026-09-14-chat-presentation-defaults.zh.md)
+
+## Problem
+
+Trajectory inspection benefits from exposing complete recorded reasoning. Applying that default to Chat expands the live transcript during reasoning and changes its height when an answer or Tool call arrives. The Trajectory inspection change also added historical first-token recovery to Chat without a separate Chat behavior decision.
+
+## Decision
+
+[Chat](../../../../packages/client/ui-chat/README.md#turn-process-folding) starts each reasoning row collapsed and retains the reader's manual disclosure choice through subsequent output and settlement. Settlement retires the observed live chunks and rebuilds Chat reply nodes from durable events without recovering first-token time from embedded streams. Consequently, completed-turn TTFT and decoding speed are absent after live settlement as well as after reopening history. Turn-level process folding remains independently owned.
+
+[Trajectory inspection](../feature/2026-09-09-ptc-trajectory-code-inspection.md) keeps its expanded reasoning default, recorded timing, JSON controls, and PTC code inspector. The [compact stream readers](../architecture/2026-09-06-embedded-stream-record-readers.md) remain available to Trajectory and other consumers. These decisions partially supersede the Chat presentation additions while preserving both notes' independent rationale.
+
+## Alternatives considered
+
+**Keep Chat's automatic expansion and historical timing recovery.** These change Chat behavior beyond the requested Trajectory inspection work. Reintroducing either requires a separate Chat product decision and its own verification.
+
+**Revert the entire inspection change.** That would remove the requested Trajectory behavior along with the unintended Chat changes.
+
+## Consequences
+
+Chat reasoning requires a click to inspect in full. Elapsed turn time and the independently projected Session Stats remain available. Assembler tests distinguish transient retirement at live settlement from reopening durable history; browser replay verifies the completed-turn timing dialog before and after reload. Component tests cover collapsed streaming and reasoning-only replies and manual disclosure across answer and Tool-call arrival. Trajectory tests retain its separate defaults and timing.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-14-chat-presentation-defaults.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 保持 Chat 展示与 Trajectory 查看独立
+
+Status: implemented
+
+[English](2026-09-14-chat-presentation-defaults.md) | 中文
+
+## 问题
+
+Trajectory 查看适合直接展示完整的已记录思考。将该默认行为应用到 Chat 会在思考期间展开实时对话,并在回答或工具调用到来时改变其高度。Trajectory 查看改动还为 Chat 增加了历史首 token 计时恢复,却没有独立的 Chat 行为决策。
+
+## 决策
+
+[Chat](../../../../packages/client/ui-chat/README.zh.md#turn-process-folding) 的每个思考行初始都折叠,后续输出和结算保留用户手动选择的展开状态。结算会移除观测到的实时 chunk,并从持久事件重建 Chat 回复节点,不从内嵌流恢复首 token 时间。因此,实时结算后和重新打开历史后,已完成轮次都不显示 TTFT 和解码速度。轮次级过程折叠仍独立维护。
+
+[Trajectory 查看](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md) 保留思考默认展开、已记录计时、JSON 控件和 PTC 代码查看器。[紧凑流读取器](../architecture/2026-09-06-embedded-stream-record-readers.zh.md) 仍供 Trajectory 和其他消费方使用。这些决策部分取代了 Chat 展示增量,同时保留两份记录各自独立的理由。
+
+## 考虑过的替代方案
+
+**保留 Chat 自动展开和历史计时恢复。** 这些改变了所请求的 Trajectory 查看工作之外的 Chat 行为。重新引入任一行为都需要独立的 Chat 产品决策及相应验证。
+
+**撤回整个查看改动。** 这会在撤回意外 Chat 改动的同时移除所请求的 Trajectory 行为。
+
+## 影响
+
+Chat 思考需要点击才能查看全文。轮次总耗时和独立投影的 Session Stats 仍可用。组装器测试区分实时结算时移除临时数据与重新打开持久历史;浏览器回放验证重载前后的已完成轮次计时对话框。组件测试覆盖流式与仅思考回复的默认折叠,以及回答和工具调用到来时的手动展开状态。Trajectory 测试保留其独立默认值和计时。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.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-09-09-ptc-trajectory-code-inspection.md
-2026-09-09-ptc-trajectory-code-inspection.md: 477d199f519b5d515e5d58430bd902d9d209e46b
-2026-09-09-ptc-trajectory-code-inspection.zh.md: f305569176214c63ac549b0ec5103289e3e57a88
+2026-09-09-ptc-trajectory-code-inspection.md: 19779d20e8d08ce0ca9678ab6626765faa13cf65
+2026-09-09-ptc-trajectory-code-inspection.zh.md: 7cdb2321d61893e43426798d311847662450d289

+ 3 - 1
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md

@@ -16,6 +16,8 @@ The result view preserves recorded text and uses a tree only for complete JSON o
 
 The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlement; the [client presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md) still owns deriving UI from recorded facts. This inspector adds no Session events or host presentation fields.
 
+Trajectory thinking opens by default and supports manual disclosure. [Chat disclosure defaults](../bug-fix/2026-09-14-chat-presentation-defaults.md) are a separate presentation decision.
+
 ## Alternatives considered
 
 **Keep source inside the argument tree.** JSON escaping obscures program structure and makes copying executable source cumbersome.
@@ -26,4 +28,4 @@ The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlemen
 
 ## Consequences
 
-Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover body arrival, manual disclosure, and switching records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip.
+Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover manual disclosure and switching Trajectory records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip.

+ 3 - 1
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md

@@ -16,6 +16,8 @@ PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树
 
 [PTC 运行时决策](2026-06-15-ptc.zh.md) 仍负责执行与结算;[客户端展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md) 仍负责从已记录事实派生 UI。此检查器不增加 Session 事件或宿主展示字段。
 
+Trajectory 思考默认展开,并支持手动展开折叠。[Chat 展开默认值](../bug-fix/2026-09-14-chat-presentation-defaults.zh.md)是独立的展示决策。
+
 ## 考虑过的替代方案
 
 **把源码保留在参数树中。** JSON 转义遮蔽程序结构,也使复制可执行源码变得繁琐。
@@ -26,4 +28,4 @@ PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树
 
 ## 后果
 
-读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖正文到达、手动展开折叠及记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。
+读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖手动展开折叠及 Trajectory 记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。

+ 1 - 4
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -291,10 +291,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
         await input.press('Enter')
         if (MODE !== 'record') {
           const thinking = page.locator('[data-variant="think"][data-state="running"]')
-          const disclosure = thinking.getByRole('button')
-          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
-          await disclosure.click()
-          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
+          await expect.poll(() => thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false')
           const liveTail = thinking.locator('[data-follow-end]')
           await expect.poll(async () => {
             if (await liveTail.count() !== 1) return false

+ 12 - 3
apps/web/tests/turn-tail-actions.e2e.ts

@@ -17,7 +17,7 @@ import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
 import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -201,13 +201,22 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     await timeTrigger.click()
     const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
     expect(await timeDialog.count()).toBe(1)
-    expect(await timeDialog.getByText(/tok\/s/).count()).toBeGreaterThan(0)
-    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1)
+    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
+    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
     await page.keyboard.press('Escape')
     await trigger.click()
 
     const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE)
+
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await expect.poll(() => timeTrigger.count(), { timeout: 15_000 }).toBe(1)
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    await timeTrigger.click()
+    expect(await timeDialog.count()).toBe(1)
+    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
+    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 120_000)

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: 254c511a023aa8217b73cc0fe3802f13f2a63d80
-README.zh.md: d79f8b5060bd6a452ddaf018074adaea88b85604
+README.md: c7c9fb7cb2a9a149fae9b27db78e019d821dfb95
+README.zh.md: 386d7aae91b157a7b09fcd0515bfd8d075a39638

Plik diff jest za duży
+ 3 - 1
packages/client/ui-chat/README.md


Plik diff jest za duży
+ 3 - 1
packages/client/ui-chat/README.zh.md


+ 1 - 8
packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx

@@ -58,8 +58,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
     return { resolve: value => localPathMediaUrl(protocol, origin, value) }
   }, [])
   const last = blocks.length - 1
-  const hasBodyOrToolCall = blocks.some(block => block.kind === 'tool-call'
-    || (block.kind === 'text' && block.text.trim() !== ''))
   // 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
   // between tool groups — skip the shell unless something visible remains.
@@ -91,12 +89,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
             hidden={reasoningHidden}
             reveal={revealProcess}
           >
-            <ReasoningRow
-              text={block.text}
-              running={streaming && i === last}
-              defaultExpanded={!hasBodyOrToolCall && block.text.trim() !== ''}
-              t={t}
-            />
+            <ReasoningRow text={block.text} running={streaming && i === last} t={t} />
           </ProcessReasoning>,
         )
         break

+ 4 - 11
packages/client/ui-chat/src/client/chat/ReasoningRow.tsx

@@ -17,23 +17,16 @@ function latestLine(text: string): string {
 }
 
 /**
- * Render one assistant reasoning block as the Think disclosure row. The
+ * Render one assistant reasoning block collapsed until the reader opens it. The
  * collapsed summary omits double-asterisk markers; expanded content preserves
  * the complete text.
  * @param props.text - complete or streaming reasoning text.
  * @param props.running - whether this block is the streaming tail.
- * @param props.defaultExpanded - expanded state until the reader toggles it.
  * @param props.t - conversation locale seat for the running status.
  * @returns the reasoning disclosure.
  */
-export function ReasoningRow({ text, running, defaultExpanded, t }: {
-  text: string
-  running: boolean
-  defaultExpanded: boolean
-  t: ChatViewSlotProps['t']
-}) {
-  const [expandedOverride, setExpandedOverride] = useState<boolean>()
-  const expanded = expandedOverride ?? defaultExpanded
+export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
+  const [expanded, setExpanded] = useState(false)
   const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
 
   return (
@@ -54,7 +47,7 @@ export function ReasoningRow({ text, running, defaultExpanded, t }: {
         open={expanded}
         expandable
         expandOnRowClick
-        onToggle={() => { setExpandedOverride(!expanded) }}
+        onToggle={() => { setExpanded(value => !value) }}
         collapsedContent={(
           <>
             <span className={css.separator} aria-hidden />

+ 2 - 21
packages/client/ui-chat/src/client/conversation-nodes/assistant.ts

@@ -4,7 +4,6 @@ import type {
   ConversationNodeContext, ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
-import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { AssistantChatData } from '../contract/chat-nodes.ts'
@@ -162,17 +161,6 @@ function updateChunk(
   }
 }
 
-/** The Step retains its first token across live chunks and settled retry attempts. */
-function settleTiming(
-  state: AssistantState,
-  event: SessionEvent<'assistant/message' | 'assistant/attempt'>,
-): AssistantState {
-  return {
-    ...state,
-    firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream),
-  }
-}
-
 function settleMessage(
   state: AssistantState,
   match: ConversationMatch,
@@ -180,7 +168,7 @@ function settleMessage(
 ): AssistantState {
   const blocks = toAssistantBlocks(event.data.message.content)
   return {
-    ...settleTiming(state, event),
+    ...state,
     blocks,
     visibleBlocks: countVisibleBlocks(blocks),
     hidden: false,
@@ -248,11 +236,6 @@ function fallbackState(context: ConversationNodeContext<AssistantState>): Assist
       state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time)
       continue
     }
-    if (match.event.type === 'assistant/attempt') {
-      state ??= initialState(match.event.data.turn, match.event.data.step)
-      state = settleTiming(state, match.event)
-      continue
-    }
     if (match.event.type === 'assistant/message') {
       state ??= initialState(match.event.data.turn, match.event.data.step)
       state = settleMessage(state, match, match.event)
@@ -313,7 +296,6 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
   match: (event) => {
     if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
     if (event.type === 'assistant/live-chunk'
-      || event.type === 'assistant/attempt'
       || (event.type === 'assistant/message' && event.surfaceOp === 'append')) {
       return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
     }
@@ -331,14 +313,13 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
       return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
     }
     if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event)
-    if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event)
     if (match.event.type === 'llm/retry') {
       return resetForRetry(context.state)
     }
     return context.state
   },
   publication: (match) => {
-    if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none'
+    if (match.event.type === 'step/start') return 'none'
     if (match.event.type !== 'assistant/live-chunk') return 'immediate'
     const type = match.event.data.chunk.type
     return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'

+ 53 - 9
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -13,6 +13,7 @@ import {
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import { inspectSystemPrompt } from '../../ui-conversation/src/client/contract/system-prompt.ts'
 import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
+import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
 import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
@@ -941,6 +942,56 @@ describe('built-in conversation node Definitions', () => {
     })
   })
 
+  it('omits first-token metrics after live settlement and after reopening the same history', () => {
+    const attemptId = LlmAttemptId('settled-chat-timing')
+    const starts = [
+      at(1, 'turn/start', { turn: 1 }, { time: 1_000 }),
+      at(2, 'step/start', { turn: 1, step: 1 }, { time: 1_010 }),
+    ]
+    const value = assembler(starts)
+    const chunk = { type: 'text-delta' as const, index: 0, text: 'Answer' }
+    value.append({
+      type: 'transient',
+      event: {
+        type: 'assistant/live-chunk', seq: 2.5, time: 1_030,
+        data: { attemptId, turn: 1, step: 1, chunk },
+      },
+    })
+    value.flush()
+    expect(node(snapshot(value), 'assistant-step')?.data).toMatchObject({
+      status: 'running', time: 1_030, blocks: [{ kind: 'text', text: 'Answer' }],
+    })
+
+    const stream = new AssistantStreamAccumulator()
+    stream.push({ time: 1_030, chunk })
+    const event = at(3, 'assistant/message', {
+      turn: 1, step: 1, message: assistantMessage('settled-timing', 'Answer'),
+      stream: stream.snapshot(), usage: { outputTokens: 10 },
+    }, { surfaceOp: 'append', time: 1_050 }).event
+    if (event.type !== 'assistant/message') throw new Error('expected Assistant settlement')
+    const settlement = { type: 'event' as const, event }
+    value.settleAssistant(attemptId, settlement)
+    const ends = [
+      at(4, 'step/end', { turn: 1, step: 1 }, { time: 1_060 }),
+      at(5, 'turn/end', { turn: 1, reason: { kind: 'completed' } }, { time: 1_070 }),
+    ]
+    for (const end of ends) value.append(end)
+    value.flush()
+
+    const reopened = assembler([...starts, settlement, ...ends])
+    for (const current of [value, reopened]) {
+      const view = snapshot(current)
+      const assistant = (node(view, 'assistant-step')?.data as AssistantChatData).finalNode
+      expect(assistant?.timing).toEqual({
+        stepStartTime: 1_010, firstTokenTime: null, completedTime: 1_050,
+      })
+      const tail = node(view, 'turn-tail')?.data as TurnTailChatData
+      expect(tail.turn).toBe(1)
+      expect(tail.ttftMs).toBeUndefined()
+      expect(tail.tokensPerSecond).toBeUndefined()
+    }
+  })
+
   it('uses live Assistant deltas without replaying settled embedded streams', () => {
     const runningHistory = [
       at(1, 'turn/start', { turn: 1 }),
@@ -1056,16 +1107,9 @@ describe('built-in conversation node Definitions', () => {
     const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     expect(finalNode).toMatchObject({
       blocks: [{ kind: 'text', text: 'done' }],
-      timing: { firstTokenTime: 1_999 },
+      timing: { firstTokenTime: null },
     })
 
-    const windowed = assembler(finalizedInputs.slice(2), true)
-    const timing = () => (node(snapshot(windowed), 'assistant-step')?.data as AssistantChatData).finalNode?.timing
-    expect(timing()).toMatchObject({ stepStartTime: null, firstTokenTime: 1_999 })
-    windowed.prepend(finalizedInputs.slice(0, 2), false)
-    windowed.flush()
-    expect(timing()).toEqual(finalNode?.timing)
-
     const namedToolHistory = [
       at(40, 'turn/start', { turn: 3 }),
       at(41, 'step/start', { turn: 3, step: 1 }),
@@ -1090,7 +1134,7 @@ describe('built-in conversation node Definitions', () => {
     const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     expect(namedTool).toMatchObject({
       blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
-      timing: { firstTokenTime: 4_000 },
+      timing: { firstTokenTime: null },
     })
   })
 

+ 15 - 17
packages/client/ui-chat/tests/reasoning-row.client.spec.tsx

@@ -14,24 +14,27 @@ const t = makeTranslate(zh, commonZh)
 const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
 
 describe('ReasoningRow', () => {
-  it('collapses when body text arrives and preserves a later manual expansion', () => {
+  it.each([
+    { kind: 'text' as const, text: 'Answer' },
+    { kind: 'tool-call' as const, callId: 'call-1', name: 'read', argsRaw: '{}' },
+  ])('starts collapsed and preserves manual expansion when $kind arrives', (nextBlock) => {
     const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' }
     const view = render(
       <AssistantMarkdown t={t} blocks={[reasoning]} streaming renderMessageImages={renderMessageImages} />,
     )
-    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
-    view.rerender(
-      <AssistantMarkdown t={t} blocks={[reasoning, { kind: 'text', text: 'Answer' }]}
-        streaming renderMessageImages={renderMessageImages} />,
-    )
     expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(view.getByText('思考'))
     view.rerender(
-      <AssistantMarkdown t={t} blocks={[reasoning, { kind: 'text', text: 'Complete answer' }]}
-        streaming={false} renderMessageImages={renderMessageImages} />,
+      <AssistantMarkdown t={t} blocks={[reasoning, nextBlock]} streaming renderMessageImages={renderMessageImages} />,
+    )
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+    view.rerender(
+      <AssistantMarkdown t={t} blocks={[reasoning, nextBlock]} streaming={false} renderMessageImages={renderMessageImages} />,
     )
     expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
     expect(view.getByText(/Check persistence/)).toBeTruthy()
+    fireEvent.click(view.getByText('思考'))
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
   })
 
   it('follows the latest streaming line, then restores the settled first line', () => {
@@ -44,8 +47,7 @@ describe('ReasoningRow', () => {
       />,
     )
     expect(view.getByText('运行中')).toBeTruthy()
-    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
-    fireEvent.click(view.getByText('思考'))
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
     expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end'))
       .toBe('true')
 
@@ -69,6 +71,7 @@ describe('ReasoningRow', () => {
       />,
     )
     const settledSummary = view.getByText('Inspect the session')
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
     expect(view.queryByText('运行中')).toBeNull()
     expect(settledSummary.parentElement?.hasAttribute('data-follow-end')).toBe(false)
   })
@@ -84,7 +87,6 @@ describe('ReasoningRow', () => {
     )
     const row = view.getByRole('button')
 
-    fireEvent.click(view.getByText('思考'))
     fireEvent.click(view.getByText('Inspect the session'))
     expect(row.getAttribute('aria-expanded')).toBe('true')
     expect(view.getByText(/Check persistence/)).toBeTruthy()
@@ -114,7 +116,6 @@ describe('ReasoningRow', () => {
       />,
     )
 
-    fireEvent.click(view.getByText('思考'))
     expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy()
     expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull()
 
@@ -122,7 +123,7 @@ describe('ReasoningRow', () => {
     expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text)
   })
 
-  it('opens reasoning-only replies as plain prose and allows manual collapse', () => {
+  it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
     const view = render(
       <AssistantMarkdown
         t={t}
@@ -131,13 +132,10 @@ describe('ReasoningRow', () => {
         renderMessageImages={renderMessageImages}
       />,
     )
-    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+    fireEvent.click(view.getByText('思考'))
     expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
     expect(view.queryByText('IN')).toBeNull()
     expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
     expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
-    fireEvent.click(view.getByText('思考'))
-    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
-    expect(view.queryByText(/Check persistence/)).toBeNull()
   })
 })

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików