Bug Description
After sending messages during tool execution (btw / mid-turn user messages), attempting to rewind produces a false error:
✕ Cannot rewind to a turn that was compressed. Try a more recent turn.
No compaction/compression actually occurred. The error message is misleading.
Reproducible in both live sessions and --continue resumed sessions. No resume is required.
Root Cause
When a user types during tool execution, the mid-turn drain in useGeminiStream.ts:2341-2353 does two things:
-
API side (line 2346): pushes the btw text into responsesToSend alongside tool results, then calls submitQuery(..., SendMessageType.ToolResult). The model receives a single user Content with both functionResponse and btw text parts — a hybrid entry.
-
UI side (line 2351): addItem({ type: MessageType.USER, text: msg }) — adds the btw as a separate type: 'user' item to the UI history.
This creates a count mismatch:
isRealUserTurn (historyMapping.ts:22) counts btw UI items because they have type: 'user'
isUserTextContent (historyMapping.ts:33) skips the hybrid API entry because it has functionResponse
computeApiTruncationIndex finds fewer API user-text entries than UI user turns → returns -1
The same mismatch also occurs on --continue resume: appendApiHistoryRecord (sessionService.ts:1228) merges btw into preceding tool_result Content, while resumeHistoryUtils.ts:290 reconstructs btw as separate { type: 'user' } items.
Call Chain (live session)
useGeminiStream.ts:2346 responsesToSend.push(btwText) → merged with functionResponse in API Content
useGeminiStream.ts:2351 addItem({ type: 'user', text }) → separate UI history item
↓
historyMapping.ts:38 isUserTextContent: hybrid has functionResponse → return false
historyMapping.ts:25 isRealUserTurn: type === 'user' → return true
historyMapping.ts:119 computeApiTruncationIndex: API count < UI count → return -1
AppContainer.tsx:2472 Error: "Cannot rewind to a turn that was compressed"
Steps to Reproduce
Quick (live session, no resume needed)
1. qwen
2. Send: "Hi,请依次调用3次工具,我测试你的工具调用相关代码"
3. While tools are executing, type "插入" and press Enter (repeat 2-3 times)
4. Wait for completion, send one more normal message
5. /rewind → select the last turn → "Restore conversation only" → ERROR
本地复现:
Reliable (synthetic JSONL)
python3 gen_session.py # see script below
cd /private/tmp/qwen-btw-repro && qwen --continue
# /rewind → select last turn (#4) → "Restore conversation only" → ERROR
gen_session.py reproduction script
#!/usr/bin/env python3
import json, uuid, os, re
SESSION_ID = str(uuid.uuid4())
CWD = "/private/tmp/qwen-btw-repro" # macOS: /tmp → /private/tmp
VERSION = "0.16.2"
BRANCH = "main"
seq = 0
last_uuid = None
def record(type_, role, parts, *, subtype=None, model=None, extra=None):
global seq, last_uuid
seq += 1
my_uuid = str(uuid.uuid4())
r = {
"uuid": my_uuid, "parentUuid": last_uuid,
"sessionId": SESSION_ID,
"timestamp": f"2026-05-27T10:00:{seq:02d}.000Z",
"type": type_, "cwd": CWD, "version": VERSION, "gitBranch": BRANCH,
}
if subtype: r["subtype"] = subtype
if model: r["model"] = model
r["message"] = {"role": role, "parts": parts}
if extra: r.update(extra)
last_uuid = my_uuid
return r
records = [
record("user", "user", [{"text": "list the files in this directory"}]),
record("assistant", "model", [
{"text": "Let me list the files.", "thought": True},
{"text": "I'll check the directory."},
{"functionCall": {"id": "call-001", "name": "run_shell_command",
"args": {"command": "ls -la"}}}
], model="test-model"),
record("tool_result", "user", [
{"functionResponse": {"id": "call-001", "name": "run_shell_command",
"response": {"output": "total 0\n."}}}
]),
# KEY: btw message — merged into tool_result in API, separate in UI
record("user", "user", [
{"text": "\n[User message received during tool execution]: what about hidden files?"}
], subtype="mid_turn_user_message",
extra={"systemPayload": {"displayText": "what about hidden files?"}}),
record("assistant", "model", [
{"text": "Checking.", "thought": True},
{"text": "Hidden files are shown with -a flag."}
], model="test-model"),
record("user", "user", [{"text": "now show me the disk usage"}]),
record("assistant", "model", [
{"text": "Checking.", "thought": True},
{"text": "Directory is empty."}
], model="test-model"),
record("user", "user", [{"text": "thanks, that's all"}]),
record("assistant", "model", [
{"text": "Done.", "thought": True},
{"text": "You're welcome!"}
], model="test-model"),
]
project_id = re.sub(r'[^a-zA-Z0-9]', '-', CWD)
chats_dir = os.path.expanduser(f"~/.qwen/projects/{project_id}/chats")
os.makedirs(chats_dir, exist_ok=True)
jsonl_path = os.path.join(chats_dir, f"{SESSION_ID}.jsonl")
with open(jsonl_path, 'w') as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + '\n')
os.makedirs(CWD, exist_ok=True)
print(f"Session: {SESSION_ID}\nJSONL: {jsonl_path}\n\ncd {CWD} && qwen --continue")
Upstream Comparison (claude-code)
Claude-code avoids this bug entirely by design — btw never touches the main conversation state:
|
claude-code |
qwen-code |
| btw mechanism |
Forked agent (runSideQuestion → runForkedAgent), independent message array |
Mid-turn drain, merged into main API history |
| btw in UI |
Overlay rendering, never enters message list |
addItem({ type: MessageType.USER }) → enters UI history |
| btw in API |
Not in main conversation messages |
Merged into tool_result Content (hybrid functionResponse + text) |
| Rewind impact |
None — btw is invisible to rewind |
UI/API count mismatch → -1 |
Key findings from claude-code source:
/btw runs via runSideQuestion() → runForkedAgent() (forked agent shares prompt cache but has independent message array)
- The btw UI is rendered as an overlay ("immediate local-jsx command"), not as a message in the message list
rewindConversationTo does simple setMessages(prev.slice(0, messageIndex)) — btw messages are never in messages
mergeUserContentBlocks does merge content into tool_result, but only for machine-generated content (hook reminders, queued-command attachments); an origin field ensures human-typed text is never smooshed
The qwen-code mid-turn drain approach (useGeminiStream.ts:2341-2353) has no equivalent in claude-code. It creates a dual-representation (separate in UI, merged in API) that was not designed to be consistent for rewind counting.
Suggested Fix
The fix needs to address both the live and resume paths:
1. Live path: useGeminiStream.ts:2351
Change btw UI items from type: 'user' to type: 'btw' (or a non-user type) so isRealUserTurn doesn't count them:
- addItem({ type: MessageType.USER, text: msg }, Date.now());
+ addItem({ type: MessageType.BTW, text: msg, btw: { question: msg, answer: '', isPending: false } }, Date.now());
2. Resume path: sessionService.ts:1228
Prevent btw from merging into tool_result entries:
- if (previous?.role === 'user') {
+ if (previous?.role === 'user' && !previous.parts?.some(p => 'functionResponse' in p)) {
3. Resume UI path: resumeHistoryUtils.ts:290
Reconstruct btw as non-user type so isRealUserTurn skips it (aligned with live path fix):
- items.push({ type: 'user', text });
+ items.push({ type: 'btw', text, btw: { question: text, answer: '', isPending: false } });
Files Involved
packages/cli/src/ui/hooks/useGeminiStream.ts — live btw UI item type (primary fix)
packages/core/src/services/sessionService.ts — appendApiHistoryRecord merge guard
packages/cli/src/ui/utils/resumeHistoryUtils.ts — btw reconstruction type
packages/core/src/services/sessionService.test.ts — update btw merge tests
packages/cli/src/ui/utils/historyMapping.test.ts — bug repro test
中文版本 / Chinese Version
Bug 描述
在工具执行期间发送消息(mid-turn user message)后,尝试 rewind 报错:
✕ 无法回退到已被压缩的轮次,请尝试更近一些的轮次。
实际没有发生任何压缩。不需要 --continue,live session 中直接可复现。
根因
用户在 tool 执行期间打字时,useGeminiStream.ts:2341-2353 的 mid-turn drain 做了两件事:
-
API 侧(line 2346):btw text 被 push 进 responsesToSend,和 tool result 一起发送。模型收到的是一个同时包含 functionResponse 和 text 的混合 user Content。
-
UI 侧(line 2351):addItem({ type: MessageType.USER, text: msg }) — btw 作为独立的 type: 'user' 条目加入 UI history。
这造成计数不匹配:
isRealUserTurn 计数 btw(因为 type === 'user')
isUserTextContent 跳过混合 API 条目(因为有 functionResponse)
computeApiTruncationIndex 找不到足够的 API user text → 返回 -1
--continue 恢复时也有同样问题:appendApiHistoryRecord 把 btw 合并进 tool_result,resumeHistoryUtils 把 btw 重建为 { type: 'user' }。
上游对比(claude-code)
claude-code 从设计上避免了这个问题——btw 完全不接触主对话状态:
|
claude-code |
qwen-code |
| btw 机制 |
forked agent,独立消息数组 |
mid-turn drain,合并进主 API history |
| btw 在 UI |
overlay 渲染,不进 message list |
addItem({ type: 'user' }) 进 UI history |
| btw 在 API |
不在主 conversation messages 中 |
合并进 tool_result Content |
| rewind 影响 |
无 |
UI/API 计数不匹配 → -1 |
qwen-code 的 mid-turn drain 方式(useGeminiStream.ts:2341-2353)在 claude-code 中没有对应实现,它创造了一种双重表示(UI 中独立、API 中合并)但未设计为对 rewind 计数一致。
复现
1. qwen
2. 发:"Hi,请依次调用3次工具,我测试你的工具调用相关代码"
3. 工具执行时打字 "插入" 并回车(重复 2-3 次)
4. 等完成后再发一条正常消息
5. /rewind → 选最后一个 turn → "仅恢复对话" → 报错
🤖 Generated with Qwen Code
Bug Description
After sending messages during tool execution (btw / mid-turn user messages), attempting to rewind produces a false error:
No compaction/compression actually occurred. The error message is misleading.
Reproducible in both live sessions and
--continueresumed sessions. No resume is required.Root Cause
When a user types during tool execution, the mid-turn drain in
useGeminiStream.ts:2341-2353does two things:API side (line 2346): pushes the btw text into
responsesToSendalongside tool results, then callssubmitQuery(..., SendMessageType.ToolResult). The model receives a single user Content with bothfunctionResponseand btwtextparts — a hybrid entry.UI side (line 2351):
addItem({ type: MessageType.USER, text: msg })— adds the btw as a separatetype: 'user'item to the UI history.This creates a count mismatch:
isRealUserTurn(historyMapping.ts:22) counts btw UI items because they havetype: 'user'isUserTextContent(historyMapping.ts:33) skips the hybrid API entry because it hasfunctionResponsecomputeApiTruncationIndexfinds fewer API user-text entries than UI user turns → returns -1The same mismatch also occurs on
--continueresume:appendApiHistoryRecord(sessionService.ts:1228) merges btw into precedingtool_resultContent, whileresumeHistoryUtils.ts:290reconstructs btw as separate{ type: 'user' }items.Call Chain (live session)
Steps to Reproduce
Quick (live session, no resume needed)
本地复现:
Reliable (synthetic JSONL)
gen_session.py reproduction script
Upstream Comparison (claude-code)
Claude-code avoids this bug entirely by design — btw never touches the main conversation state:
runSideQuestion→runForkedAgent), independent message arrayaddItem({ type: MessageType.USER })→ enters UI historymessagestool_resultContent (hybridfunctionResponse+text)-1Key findings from claude-code source:
/btwruns viarunSideQuestion()→runForkedAgent()(forked agent shares prompt cache but has independent message array)rewindConversationTodoes simplesetMessages(prev.slice(0, messageIndex))— btw messages are never inmessagesmergeUserContentBlocksdoes merge content intotool_result, but only for machine-generated content (hook reminders, queued-command attachments); anoriginfield ensures human-typed text is never smooshedThe qwen-code mid-turn drain approach (
useGeminiStream.ts:2341-2353) has no equivalent in claude-code. It creates a dual-representation (separate in UI, merged in API) that was not designed to be consistent for rewind counting.Suggested Fix
The fix needs to address both the live and resume paths:
1. Live path:
useGeminiStream.ts:2351Change btw UI items from
type: 'user'totype: 'btw'(or a non-user type) soisRealUserTurndoesn't count them:2. Resume path:
sessionService.ts:1228Prevent btw from merging into tool_result entries:
3. Resume UI path:
resumeHistoryUtils.ts:290Reconstruct btw as non-user type so
isRealUserTurnskips it (aligned with live path fix):Files Involved
packages/cli/src/ui/hooks/useGeminiStream.ts— live btw UI item type (primary fix)packages/core/src/services/sessionService.ts—appendApiHistoryRecordmerge guardpackages/cli/src/ui/utils/resumeHistoryUtils.ts— btw reconstruction typepackages/core/src/services/sessionService.test.ts— update btw merge testspackages/cli/src/ui/utils/historyMapping.test.ts— bug repro test中文版本 / Chinese Version
Bug 描述
在工具执行期间发送消息(mid-turn user message)后,尝试 rewind 报错:
实际没有发生任何压缩。不需要
--continue,live session 中直接可复现。根因
用户在 tool 执行期间打字时,
useGeminiStream.ts:2341-2353的 mid-turn drain 做了两件事:API 侧(line 2346):btw text 被 push 进
responsesToSend,和 tool result 一起发送。模型收到的是一个同时包含functionResponse和text的混合 user Content。UI 侧(line 2351):
addItem({ type: MessageType.USER, text: msg })— btw 作为独立的type: 'user'条目加入 UI history。这造成计数不匹配:
isRealUserTurn计数 btw(因为type === 'user')isUserTextContent跳过混合 API 条目(因为有functionResponse)computeApiTruncationIndex找不到足够的 API user text → 返回 -1--continue恢复时也有同样问题:appendApiHistoryRecord把 btw 合并进 tool_result,resumeHistoryUtils把 btw 重建为{ type: 'user' }。上游对比(claude-code)
claude-code 从设计上避免了这个问题——btw 完全不接触主对话状态:
addItem({ type: 'user' })进 UI historyqwen-code 的 mid-turn drain 方式(
useGeminiStream.ts:2341-2353)在 claude-code 中没有对应实现,它创造了一种双重表示(UI 中独立、API 中合并)但未设计为对 rewind 计数一致。复现
🤖 Generated with Qwen Code