Qwen Code 取消恢复机制:流式输出被 Ctrl+C 中断后如何保留部分响应并还原可编辑 Prompt
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
导读
本篇基于 Qwen Code 仓库中的交互回归测试场景(2026-07-18-cancelled-prompt-restore.md)展开,聚焦终端交互模式下按Ctrl+C取消正在进行的流式响应时的三项关键行为:部分输出是否安全地保留在对话记录中、已提交的 Prompt 是否被还原回输入框并允许继续编辑、以及各类边界场景(响应期间打字、排队中的追问、工具调用中、无任何输出时)是否不会互相干扰。读完本文,你将掌握 Qwen Code 取消恢复的完整行为契约、背后的 prompt-stash 与流缓冲快照机制,以及对应的自动化验证方法。
场景:流式输出期间的 Ctrl+C 取消
在 Qwen Code 的交互式(REPL)模式下,取消恢复的核心体验可以用下面这组步骤完整复现(对应原文档的 Scenario 章节):
- 启动 Qwen Code,进入交互模式;
- 提交一条需要较长生成时间的 Prompt,例如
Explain how a hash table handles collisions in detail.; - 等待模型开始输出、响应文本已经在界面上可见时,按下
Ctrl+C; - 确认已经生成的那部分响应仍然保留在对话记录(transcript)中,没有被一并抹掉;
- 确认刚刚提交的那条 Prompt 被恢复(restore)回输入框,且可以直接编辑后再次提交。
这是一个与 Claude Code 交互习惯对齐的体验:取消请求不等于销毁这条对话。用户中断的只是"继续生成",而已经产生的输出和这条 Prompt 本身都应当被保留,以便用户微调后重试,或者从已有内容继续追问。
四条回归检查:取消恢复的行为边界
原文档的 Regression checks 章节定义了四条必须长期成立的行为边界,它们决定了"取消恢复"功能不会被一个修复引入的副作用破坏:
| 回归检查 | 期望行为 | 源码侧对应验证 |
|---|---|---|
| 响应运行期间输入框内已有草稿 | 恢复 Prompt 时不得覆盖用户当前正在编辑的草稿文本 | prompt-stash.ts 中restorePromptStash仅在currentText.length === 0时才执行onRestore |
| 队列中已有排队的后续追问 | 输入框应保持显示排队中的追问,而不得被旧 Prompt 顶替 | AppContainer的取消处理器对排队消息队列与 auto-restore 分支做了互斥处理 |
| 工具调用(tool call)期间取消 | 保持既有的工具执行行为,不因取消恢复逻辑而改变工具调用生命周期 | 取消只中断 LLM 流,工具执行状态机维持原语义 |
| 尚未产生任何响应时取消 | 与以前一致:回退(rewind)空回合,即撤销刚提交的 user 条目与尾部 INFO 条目,并把 Prompt 拉回输入框 | AppContainer.test.tsx 中 "auto-restores the just-submitted prompt when cancelling before any meaningful output" 用例 |
其中"无输出即回退空回合"这一条尤其关键:当模型尚未产出任何有意义的文本时,这次提交等同于一次误操作,因此完整撤销(truncate 回退到提交前、清掉Request cancelled.提示条目)是更符合直觉的行为;而一旦产生了有意义的内容,则采用只还原 Prompt、不回退输出的策略(见下文"两条恢复路径")。
两条恢复路径:源码层面的判定逻辑
AppContainer的取消处理器根据"是否产生了有意义内容"来选择两种完全不同的恢复策略,这在测试中体现为两条对称的用例:
- 产生了有意义内容:调用
mockSetText('what time is it?')还原 Prompt,但不调用truncateToItem,即保留已产出的模型输出与对话记录(AppContainer.test.tsx); - 没有任何输出:走回退路径,调用
truncateToItem连同Request cancelled.提示一起回滚,同时把 Prompt 文本写回输入框(AppContainer.test.tsx)。
而"这次取消是否产生了内容"这个判断依赖一个同步快照(info.pendingItem)。测试中专门有一条用例指出:流式 chunk 到达 →cancelOngoingRequest通过addItem提交 → 在 React 重渲染之前触发onCancelSubmit,此时外部消费方读到的pendingLlmHistoryItemsprop 仍然是[](陈旧状态)。因此不能依赖 React state,而必须使用取消回调中同步传递的info.pendingItem快照覆盖陈旧的 React 状态副本(对应 AppContainer.test.tsx 中 "does not auto-restore when the sync pendingItem snapshot has meaningful content" 用例)。
此外,还有一系列防御性守卫被测试覆盖,例如:
lastTurnUserItem.text与候选 user 条目文本不一致时不执行 auto-restore(防止误截断更早的 Prompt);lastTurnUserItem.id与候选条目不匹配时不执行 auto-restore(防止addItem去重生成新 id 造成误判);- Cron / 斜杠命令
submit_prompt等未新增 user 条目的回合不触发 auto-restore。
流冲刷竞态:取消前必须先 flush 缓冲事件
取消恢复里最容易出问题的不是"恢复"本身,而是取消瞬间流事件仍被缓冲在内存中。useLlmStream对内容事件存在节流(throttle)缓冲:事件先进入bufferedEvents,再按节流窗口批量刷入状态,因此"取消时pendingHistoryItemRef仍为 null"是完全可能出现的合法状态。
针对这一竞态,use-llm-stream.test.tsx中有一条专门的回归用例(use-llm-stream.test.tsx),其验证策略极具代表性:
- 构造一个异步生成器,先
yield一条Content事件(partial response),然后await一个被挂起的 Promise,让流保持打开; - 提交查询后只让微任务排空、不推进节流定时器,从而确保
pendingHistoryItems保持为空、内容仍困在bufferedEvents中; - 此时调用
cancelOngoingRequest(); - 断言取消回调收到的
info.pendingItem中已经包含文本partial response。
这个用例的注释点明了根因:如果先对pendingHistoryItemRef.current做快照、再 flush,内容事件就会滞留在bufferedEvents中而对快照不可见,info.pendingItem传回AppContainer时会是 null,进而导致 auto-restore 误把刚刚提交的有意义内容截断掉。因此取消路径必须先 flush 缓冲、再快照("The cancel path flushed FIRST, then snapshotted")。同文件还有一条配套用例 "flushes buffered content before cancellation"(use-llm-stream.test.tsx),覆盖相同场景下的内容可见性。
与之并列的另一条防御性用例验证:onCancelSubmit抛出异常时,try/finally仍保证setIsResponding(false)与setShellInputFocused(false)执行,避免流被永久卡在 Responding 状态导致 ESC 后续失效(use-llm-stream.test.tsx)。
Prompt Stash:跨会话的 Prompt 持久化还原
除了取消恢复之外,Qwen Code 还提供了一套独立的"Prompt 暂存"机制,用于进程重启等场景下还原输入框内容。其实现位于 packages/cli/src/services/prompt-stash.ts,包括四个函数:
savePromptStash(targetDir, text):把当前输入框文本原子写入项目目录下的prompt-stash.json(version: 1结构),文件权限0o600、目录0o700,并启用atomicWriteFileSync与noFollow防止符号链接攻击;loadPromptStash(targetDir):读取并校验暂存文件,缺失或格式非法时静默返回null(注释明确"A missing or malformed stash must never prevent CLI startup");restorePromptStash(targetDir, currentText, onRestore):只有暂存存在且当前输入框为空时才执行onRestore——这与取消恢复"不覆盖草稿"的边界完全一致;clearPromptStash(targetDir):删除暂存文件,ENOENT视为成功。
对应的AppContainer测试覆盖了暂存恢复的若干细节:恢复的 Prompt 被视为"provenance unavailable"(AppContainer.test.tsx),手动清空输入框后恢复的撤销历史(undo history)被清空(AppContainer.test.tsx),以及编辑后的恢复与同文本历史重提交相互区分(AppContainer.test.tsx)。
自动化验证:如何运行回归测试
原文档给出的自动化验证命令直接作用于 CLI 包的组件层与 Hook 层,均可本地复现:
cd packages/cli npx vitest run src/ui/AppContainer.test.tsx npx vitest run src/ui/hooks/useGeminiStream.test.tsx -t 'flushes buffered stream events before snapshotting'注意:文档中的useGeminiStream是历史命名,当前仓库中该 Hook 已演进为useLlmStream,对应的测试文件为src/ui/hooks/use-llm-stream.test.tsx,上述-t过滤的用例名在现仓库中以 "flushes buffered stream events before snapshotting pendingItem so cancelling mid-throttle does not lose content" 的形式存在,可直接用新文件名运行:
cd packages/cli npx vitest run src/ui/AppContainer.test.tsx npx vitest run src/ui/hooks/use-llm-stream.test.tsx -t 'flushes buffered stream events before snapshotting'原文档记录的运行结果为:119 个 AppContainer 测试全部通过,流冲刷竞态回归用例通过。这一数字可作为本地运行后的对照基准。
手动验证状态与局限说明
原文档在 Manual status 章节如实记录了局限性:当前环境因缺少一个可用于 before/after 对比的已发布全局qwen可执行文件,手动端到端验证未在此环境中执行。也就是说,该功能的验证目前完全依赖上述自动化测试覆盖,手动确认(步骤 4 观察 transcript 中保留部分响应、步骤 5 观察输入框恢复 Prompt)仍有待在有可用发布包的终端环境中补做。
这一说明本身也是一条重要的工程实践提示:交互式 UI 的取消/恢复行为难以在无头环境完整复现,因此在设计回归测试时,把行为契约拆解为可单测的判定分支(是否产生内容、是否匹配lastTurnUserItem、快照是否同步)并用组件级与 Hook 级测试钉死,是比依赖手工验证更可靠的策略。
总结
Qwen Code 的取消恢复机制由三层构成:行为契约(保留部分输出、还原可编辑 Prompt、四条边界检查)、实现机制(先 flush 缓冲再同步快照的pendingItem、按内容有无分叉的回退/还原路径、prompt-stash 跨会话暂存),以及验证体系(AppContainer与useLlmStream的 119 个用例)。对于想要为类似终端 AI 交互工具实现"取消不丢内容"体验的开发者,本仓库的这套设计与测试可以作为直接参考的实现蓝本。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考