fix(chat): keep a mid-turn message out of the running round's anchor - #708
Merged
xintaofei merged 4 commits intoSep 22, 2026
Merged
Conversation
This was referenced Sep 10, 2026
Two timeline rules hide a persisted assistant turn while a reply streams:
the `liveOwnsActiveTurn` tail strip, which drops everything after the last
persisted user turn for a delegation-child viewer, and the
`in_flight_user_turn_id` suppression, which drops the partial assistant turn
OpenCode and Gemini persist mid-stream. Both are sound only because the live
stream is showing that same reply, and both asked whether a live message
OBJECT existed rather than whether it renders anything.
Those two differ, routinely. `status_changed` to `prompting` installs a
fresh `content: []` live message at the start of every turn and the dispatch
mirror pushes it into the runtime store, which
acp-connections-context.test.tsx already pins ("fires with isLive=true and a
fresh non-null liveMessage when a turn starts"). The mirror never writes a
null back over it, so the same object stays in hand through any stretch of a
turn that produces nothing the streaming build renders. In that state the
persisted reply was hidden with nothing put in its place: a blank agent
turn.
The sub-agent dialog reaches it on an ordinary path. `LiveTranscriptView`
marks the session `liveOwnsActiveTurn` on mount, bridges whatever live
message the child's connection holds, and fetches with `preserveLive: true`,
so when the child starts its next turn the strip runs against an empty live
message and the round already on screen disappears until the first chunk
arrives. The child CLI writes its JSONL asynchronously, so a refetch inside
that window keeps the same shape.
`computeTimeline` now builds the streaming tail first and passes the verdict
down: a live message suppresses a persisted reply only when it produced an
assistant turn. Deriving that from the same build is what keeps the two from
disagreeing again. A live message carrying only a mid-turn steering message
counts as showing no reply, since that turn is the user's, not a rendering
of the reply it would otherwise hide.
`pendingOutOfTurnContent` became a required field of `ConversationRuntimeSession` after this branch was cut, and the new test builds a session literal by hand. Neither `pnpm build` nor `pnpm test` typechecks test files, so only `tsc --noEmit` sees it.
A message sent mid-turn is a user turn the agent writes into the MIDDLE of a round, and both rules that hide the persisted copy of the reply being streamed locate that round by its newest persisted user turn. Once the agent records the steered message, the viewer's persisted-tail strip anchors on it and stops stripping the reply's first half; and the backend can no longer stamp `in_flight_user_turn_id` at all, because `apply_in_flight_message_id` matches the pending prompt against the transcript TAIL and the tail is now that message, so the cross-client suppression switches itself off in the middle of the round it exists for. Either way the already-persisted first half lands beside the live copy of the same text, and `mergeConsecutiveAssistantTurns` glues the two into one bubble: the run-on reply the mid-turn split was added to prevent, back again for every turn that is actually steered. Both anchors now step over the detail's own copies of this turn's mid-turn messages, reusing the set `suppressPersistedSteeredPrompts` already matches by content and injection instant so that one rule decides what a steered copy is. The step is gated on the live message holding a block from BEFORE the first interruption: a session that adopted a snapshot mid-turn holds no steering block at all, so a steer arriving afterwards can be the first thing it ever saw of the turn, and hiding the persisted first half there would put it nowhere. Six of the new tests fail without the change; the other five pin the behaviour that must not move (an earlier round's identical words, an unsteered turn, the backend's stamp while it is there).
`apply_in_flight_message_id` matched the transcript TAIL — the trailing
user turn, or the one before a single trailing assistant turn. The tail
is the prompt only while the agent has written nothing else, and two
ordinary things put something there:
- a message the user sends mid-turn is written into the transcript as
a USER turn after the prompt, so the tail becomes that message;
- OpenCode and Gemini persist the reply as it goes, and a parser that
splits it leaves two or more assistant turns behind the prompt.
In both the backend then reported no in-flight id at all, in the middle
of the running turn, and every consumer reads that as 'settled':
`computeTimelinePrefix` stops hiding the persisted half of the reply the
live stream is re-showing, `detailIsInFlight` lets a mid-turn refetch
clear `liveMessage` / `localTurns` / `optimisticTurns`, and
`collectInFlightPersistedToolCalls` stops marking the round's unfinished
tool calls so they paint as completed.
It now walks back over the turns this turn produced and stamps the
EARLIEST user turn whose content is the prompt's. Earliest, because the
agent writes the prompt before anything it produces, so a mid-turn
message repeating its words ('continue' twice) cannot take the stamp.
The recency gate is what makes the walk safe and what bounds it: it
stops at the first turn older than the turn's start, so an identical
prompt from an earlier round stays out of reach — the walk never reads
past the running turn.
The structural bound this replaces predates that gate and was redundant
beside it; `does_not_stamp_with_two_trailing_assistant_turns` pinned the
cost rather than a requirement, and is now the positive case.
xintaofei
force-pushed
the
fix/mid-turn-insert-hardening
branch
from
September 22, 2026 00:16
4ff07ca to
099a57d
Compare
xintaofei
added a commit
that referenced
this pull request
Sep 22, 2026
This one is about staying up. An agent that refuses a slash command, a grok handshake, a Cursor install on Linux, a long turn with thousands of tool calls, a crash with nothing in the log — each used to end the session you were in, and none of them does now. Alongside that: Settings gives collaboration and the built-in browser pages of their own, the file tree can copy the file itself, and thinking blocks stop opening and closing under you mid-answer. ## New - **Settings splits "Collaboration" and "Browser" out of General** — delegation and in-conversation tools on one page, the built-in browser on another, unfolded. - **The file tree's right-click menu becomes a "Copy" submenu** — relative path, absolute path, or the file itself onto the system clipboard (desktop only). - **Cursor's own extension calls are answered instead of refused**, so a Task spawn no longer shows a red "Method not found" banner. (#783 reported by @goon-13, #785, @goon-13) - **A crash now leaves a record in the log** — message, location, version and backtrace, written before the process dies. (#703, @Adam-Dalloul) ## Improved - **Updated bundled agents:** Grok 1.0.40, OpenCode 1.18.32, CodeBuddy 2.156.0, Cursor 2026.09.18, Qoder 1.1.60, Hermes 0.21.4. - **A thinking block stays folded until you open it, and stays open once you have.** - **File and folder rows line up at every depth**, with the redundant folder icon gone and the indent tightened. - **Backspace removes a reference badge in one press** instead of first eating the invisible space behind it. - **The composer is usable on mobile web again** — the editable area no longer collapses, and a tap anywhere in it opens the keyboard. (#746 reported by @evepupil, #779, @evepupil) - **File attachments survive a reload** on custom ACP agents and grok, and an attachment-only chat is named after the file. (#780, @tangsenfei) - **A reply's footer keeps filling in for agents that flush late**, so deepseek replies get their model, tokens and time — and "fork from here" stays available. - **The default-terminal picker says what your choice actually resolves to**, and badges a shell that is not installed. ## Fixed - **An agent rejecting a prompt no longer tears the session down** — Qwen Code refusing `/mcp` left the composer greyed out until a full respawn. (#797 reported by @linshaobao) - **Grok 1.0.40 connects again** — its new setup frames carry no session id yet, which read as a protocol error. (#794 reported by @qingyueyin) - **Cursor works on Linux, and an expired login now says so** instead of reading green while every session fails. - **Everything after a Claude Code `/clear` stays visible on reopen.** (#766 reported by @andrehqh, #778, @Frank-zhu0404) - **A long turn no longer freezes the session** — its tool-call history was resent whole on every attach (26.7 MB measured). (#380 reported by @ashlovepink, #712, @Adam-Dalloul) - **An agent reply no longer renders blank**, which the sub-agent dialog hit on an ordinary path. (#705, @Adam-Dalloul) - **A message sent mid-turn no longer duplicates the first half of the reply.** (#708, @Adam-Dalloul) - **Built-in browser:** a Google sign-in popup leaves no empty tab behind, a page redirecting mid-load is no longer covered by an error page, and the first blank tab stops spinning. - **A deleted or manually closed Office preview no longer reopens itself.** (#795, @Adam-Dalloul) - **Codex sessions are titled after your message**, not after the `AGENTS.md` fragment injected ahead of it. (#789 reported by @zhoujh78, #790, @dawNotPoi) - **The scientific research pack installs for Antigravity, Grok, Cursor, DeepSeek and Qoder**, whose toggles used to flip themselves back off. (#718 reported by @sunnyhmz7010, #793, @dawNotPoi) - **Launching at Windows login works within the first half-minute after boot.** (#703, @Adam-Dalloul) - **A Telegram group message can no longer crash the polling loop.** (#703, @Adam-Dalloul) Thanks to @Adam-Dalloul, @goon-13, @evepupil, @tangsenfei, @dawNotPoi and @Frank-zhu0404 for contributing to this release, and to @linshaobao, @qingyueyin, @andrehqh, @ashlovepink, @zhoujh78 and @sunnyhmz7010 for the reports. ----------------------------- # 发布版本 0.31.2 这一版的主题是「别断」。智能体拒绝一条斜杠命令、grok 的握手、Linux 上的 Cursor、一轮上千次工具调用的长任务、一次没留下日志的崩溃——它们此前都会让你正在进行的会话直接结束,现在都不会了。 同期还有:设置里的协作与内置浏览器各自独立成页,文件树可以直接复制文件本身,思考块不再在你读到一半时自己开合。 ## 新增 - **设置页把「协作」和「浏览器」从「通用」里拆了出来**——委派与会话内工具开关合为一页,内置浏览器独立一页且不再折叠。 - **文件树右键菜单从单条「复制路径」变成「复制」子菜单**——相对路径、绝对路径,或把文件本身放进系统剪贴板(仅桌面端)。 - **Cursor 自有的扩展调用不再被拒绝**,每次 Task 启动不会再糊上一条红色的「Method not found」横幅。(#783 由 @goon-13 反馈,#785,@goon-13) - **崩溃现在会在日志里留下记录**——进程退出前写入 panic 信息、代码位置、版本号和调用栈。(#703,@Adam-Dalloul) ## 改进 - **内置智能体版本更新:** Grok 1.0.40、OpenCode 1.18.32、CodeBuddy 2.156.0、Cursor 2026.09.18、Qoder 1.1.60、Hermes 0.21.4。 - **思考块在你点开之前保持折叠,点开之后就一直展开。** - **文件与目录在任意层级都对齐**,多余的文件夹图标去掉,缩进也收紧了。 - **退格键一次就能删掉引用胶囊**,不必先删掉它后面那个看不见的空格。 - **手机 Web 上的输入框恢复可用**——可编辑区域不再被压成 0 高度,点击编辑区任意位置都能唤起键盘。(#746 由 @evepupil 反馈,#779,@evepupil) - **自定义 ACP 智能体和 grok 的文件附件在刷新后不再丢失**,只带附件的对话会以附件文件名命名。(#780,@tangsenfei) - **回复页脚会为落盘较慢的智能体持续补齐信息**,deepseek 的回复能拿到模型、Token 用量和完成时间,「从此处分叉」也不再置灰。 - **默认终端选择器会显示你所选项真正解析到的程序**,未安装的终端明确标注。 ## 修复 - **智能体拒绝一条提示不再拆掉整个会话**——Qwen Code 拒绝 `/mcp` 后,输入框会一直置灰到会话重建完成。(#797 由 @linshaobao 反馈) - **Grok 1.0.40 可以正常连接**——它新增的初始化通知在会话 id 生成前为 null,被当成了协议错误。(#794 由 @qingyueyin 反馈) - **Linux 上的 Cursor 能用了,过期登录也会如实显示**,不再一边显示绿色一边每次发送都失败。 - **Claude Code `/clear` 之后的内容重新打开对话仍然可见。**(#766 由 @andrehqh 反馈,#778,@Frank-zhu0404) - **长任务不再让会话卡死**——此前每次接入都要重传整轮工具调用历史(实测 26.7 MB)。(#380 由 @ashlovepink 反馈,#712,@Adam-Dalloul) - **智能体回复不再出现整段空白**,子智能体弹窗在正常使用中就会碰到。(#705,@Adam-Dalloul) - **回合中途插话不再让回复前半段重复一遍。**(#708,@Adam-Dalloul) - **内置浏览器:** Google 登录弹窗不再留下空白标签页,加载中途跳转的页面不会被错误页盖住,首个空白标签页不再一直转圈。 - **已删除或手动关闭的 Office 预览不会再自己弹回来。**(#795,@Adam-Dalloul) - **Codex 会话标题取自你的消息**,而不是注入在前面的 `AGENTS.md` 片段。(#789 由 @zhoujh78 反馈,#790,@dawNotPoi) - **科学研究技能包对 Antigravity、Grok、Cursor、DeepSeek、Qoder 都能装上**,开关不会再自己刷回关闭。(#718 由 @sunnyhmz7010 反馈,#793,@dawNotPoi) - **开机自启在 Windows 刚启动的半分钟内不会再崩溃。**(#703,@Adam-Dalloul) - **Telegram 群消息不会再让轮询循环崩溃。**(#703,@Adam-Dalloul) 感谢 @Adam-Dalloul、@goon-13、@evepupil、@tangsenfei、@dawNotPoi、@Frank-zhu0404 为本次发布做出的贡献,也感谢 @linshaobao、@qingyueyin、@andrehqh、@ashlovepink、@zhoujh78 和 @sunnyhmz7010 的反馈。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sending a message mid-turn shows it as a user bubble and splits the assistant
group so the two replies do not concatenate. Once the agent writes that message
into its own transcript, both rules that hide the persisted copy of the reply
being streamed lose track of the round, and the reply's first half renders twice.
The viewer's persisted-tail strip anchors on the newest persisted user turn, which
is now the steered message, so it stops stripping the first half of the reply the
live stream is re-showing.
The cross-client suppression anchors on
in_flight_user_turn_id, which the backendstops stamping entirely:
apply_in_flight_message_idmatches the pending promptagainst the transcript tail, and the tail is the steered message.
Both now step over the detail's own copies of this turn's mid-turn messages, reusing
the set
suppressPersistedSteeredPromptsalready matches by content and injectioninstant. The step is gated on the live message holding a block from before the first
interruption, so a session that adopted a snapshot mid-turn (no steering block, so
the steer can be the first thing it saw of the turn) keeps the persisted first half
rather than hiding it with nothing in its place.
Eleven tests: six fail without the change, five pin what must not move (an earlier
round's identical words, an unsteered turn, the backend's stamp while it is there,
the same words steered twice in one turn, a steer that carried an attachment).