DSH / Atlas
2026-08-05implementedbug-fix

Turn-tail IconActions require a completed turn

轮次尾部 IconActions 要求轮次已完成

Assistant IconActions were derived from the finalized transcript alone: the last content-text assistant of each turn owned the row. That quantity is stable only after the turn closes. While a turn is still producing steps, the narration a model writes before a tool call *is* the last content assistant so far, so it took the row for as long as the tool ran and then lost it to the next step's text. Readers saw copy, br

English

Problem

Assistant IconActions were derived from the finalized transcript alone: the last content-text assistant of each turn owned the row. That quantity is stable only after the turn closes. While a turn is still producing steps, the narration a model writes before a tool call is the last content assistant so far, so it took the row for as long as the tool ran and then lost it to the next step's text. Readers saw copy, branch, and a clock appear under an intermediate sentence, shift the flow by one 28px row, and disappear. The row was also incoherent in that state: its branch control was already disabled through turnEnds, and its Ran for label was already withheld through turnTimings, so only copy worked.

The archived message-chrome decision always claimed mid-turn narration stays chrome-free; the derivation never carried a completion signal to make that true.

Decision

assistantActionsSeqs takes ConversationSnapshot.turnEnds and grants the row only within a turn that has a turn/end in the window. Ownership inside a completed turn is unchanged: its last content-text assistant. A turn still producing steps grants nothing, so its narration never mounts the row, and the seat appears once, under the settled answer, when the turn closes.

This is the same completion fact the branch control and the run-time label already use, so the three parts of one row now agree. Turn completion is read from the durable turn/end event rather than inferred from running, the streaming partial, or in-flight tool calls, matching the completed-turn-tail decision. Every reason kind closes a turn, so an aborted turn's frozen tail keeps its footer, and a crash-orphaned turn receives its turn/end from log repair on load.

hasContentText moves to chat-flow.ts and AssistantMarkdown imports it, so the ownership gate and the mount gate cannot drift apart.

Alternatives considered

Withhold by naming the open turn from running plus the streaming partial or the first in-flight tool call. This shipped briefly in the original change and was then dropped. It infers completion instead of reading it, needs a special case so a turn accepted before its first step does not strip the previous answer's seat, and is the inference the completed-turn-tail decision rejected for the branch control. turnEnds answers the same question per turn with no inference and no special case.

Leave the row mounted mid-turn and disable its controls. Rejected: mid-turn narration is not a degraded answer, it is not the answer. Copy would still write an intermediate sentence, and the row would still move to the real tail at turn end.

Keep the row under every finalized content node permanently. Rejected again here for the reason the original decision gave: repeating copy, branch, and a clock under every step clutters the flow. It also does not solve the reported problem, since the branch control is only meaningful on the tail.

Consequences

A running turn carries no message footer below the user bubble that triggered it, while every earlier completed turn keeps its own; the seat appears once when turn/end lands, which adds one 28px row under the settled answer at that moment. A turn whose turn/end is outside the loaded window grants nothing, which cannot arise from paging because a turn's end follows its own nodes. apps/web/tests/turn-tail-actions.e2e.ts pins both states through the assembled application: a hang sidecar on the second model call parks a turn whose first step narrated before calling bash, and the two goldens hold the parked flow and the flow after stopping. Package tests cover the derivation directly and the running-turn render.

中文

问题

assistant IconActions 此前只从已定稿的 transcript(文本记录)推导:每个轮次中最后一条含内容文本的 assistant 拥有该行。这个量只有在轮次关闭后才稳定。轮次仍在产出步骤时,模型在工具调用前写下的叙述就是当时该轮次的最后一条内容 assistant,于是它在工具执行期间取得该行,等下一步的文本落定又把它交出去。读者会看到复制、分支和时钟出现在一句中间叙述下方,把流程推开一行 28px,然后消失。该行在这个状态下本身也是残缺的:分支控件已经通过 turnEnds 判定为禁用,Ran for 标签已经通过 turnTimings 判定为不显示,只有复制可用。

已归档的消息 chrome 决策一直声称轮次中间的叙述不带 chrome,但推导过程从未拿到能让这句话成立的完成信号。

决策

assistantActionsSeqs 接收 ConversationSnapshot.turnEnds,只在事件窗口中存在该轮次 turn/end 时才授予该行。已完成轮次内部的归属不变,仍是其最后一条含内容文本的 assistant。仍在产出步骤的轮次不授予任何座位,因此其叙述不会挂载该行;轮次关闭时,座位在已定稿答案下方一次性出现。

这与分支控件和运行时长标签使用的完成事实相同,因此同一行的三个部分现在口径一致。轮次是否完成读自持久的 turn/end 事件,而不是从 running、流式 partial 或在途工具调用推断,与已完成轮次尾部决策一致。任何结束原因类别都会关闭轮次,因此已中止轮次冻结的尾部保留其操作栏,而崩溃遗留的开放轮次会在加载时由日志修复补上 turn/end

hasContentText 移入 chat-flow.ts,由 AssistantMarkdown 导入,使归属门控与挂载门控无法各自漂移。

考虑过的替代方案

running 加流式 partial 或第一个在途工具调用指认开放轮次,据此扣留。 这一做法曾在最初的变更中短暂存在,随后被删除。它推断完成状态而不是读取完成状态,还需要一个特例,避免轮次已被接受但尚未产出第一步时把上一条回答的座位取走;这正是已完成轮次尾部决策为分支控件否决过的推断。turnEnds 按轮次回答同一个问题,不需要推断,也不需要特例。

轮次进行中保留该行,只把控件置为不可用。 不予采纳:轮次中间的叙述不是一个降级的答案,它根本不是答案。复制仍然会写入一句中间文本,该行在轮次结束时仍然要移动到真正的尾部。

让每个已定稿的内容节点长期保留该行。 在此重新否决,理由与最初的决策相同:在每一步下重复复制、分支和时钟会使流程显得杂乱。它也解决不了本次报告的问题,因为分支控件只有落在尾部才有意义。

后果

运行中的轮次在触发它的用户气泡之下不再有任何消息操作栏,而更早的每个已完成轮次仍保留各自的座位;座位在 turn/end 到达时一次性出现,此刻已定稿答案下方会多出一行 28px。turn/end 落在加载窗口之外的轮次不授予座位,而翻页不会造成这种情况,因为一个轮次的结束事件排在它自己的节点之后。apps/web/tests/turn-tail-actions.e2e.ts 通过组装后的应用钉住两种状态:hang sidecar 作用在第二次模型调用上,把一个首步先叙述再调用 bash 的轮次挂住,两份预期输出分别记录挂起中的流程和停止之后的流程。包级测试直接覆盖该推导以及运行中轮次的渲染结果。