Skip to content

fix(rewind): false "compressed turn" error after --continue with btw messages #4579

@doudouOUC

Description

@doudouOUC

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:

  1. 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.

  2. 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

本地复现:

Image

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 (runSideQuestionrunForkedAgent), 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.tsappendApiHistoryRecord 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 做了两件事:

  1. API 侧(line 2346):btw text 被 push 进 responsesToSend,和 tool result 一起发送。模型收到的是一个同时包含 functionResponsetext 的混合 user Content。

  2. 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

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions