本文说明回合运行中继续发消息的两种方式,以及它们在终端上的表现。相关行为和色值都有测试保证,见文末「测试覆盖」。
回合运行中你还可以继续输入,消息有两种发送方式:
- 排队:等这一回合全部结束后,它作为新的一回合开始处理。
- 插队:在这一回合的下一个步骤开始之前交给模型,和当前这次请求一起处理。
差别不在排在哪条队列里,而在模型什么时候能看到它。
一个回合由若干步骤组成。每个步骤是一次模型请求,以及这次请求调用的工具。工具执行完、下一次模型请求还没发出的那段时间点,就是一个步骤边界。
上游把待发消息放在两条队列里(@deepseek-ai/dsh-agent 的 Agent.inbox):
| 队列 | 由谁写入 | 模型什么时候看到 |
|---|---|---|
next-turn |
followup() —— 排队 |
当前回合结束之后,作为新的一回合 |
next-step |
steer() —— 插队 |
当前回合的下一个步骤边界,和当前请求一起 |
两条队列的取出顺序写在上游 packages/core/agent-loop/src/inbox.ts 的 claim() 里:
claim(target, turn) {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
if (target === 'next-turn') claimed.push(...this.mutate('next-turn', 0, 1, [], false))
for (const message of claimed) this.dispatch.emit('agent/inbox/claimed', { message, turn })
return claimed
}第一行取走全部 next-step,第二行只在新回合开始时取走一条 next-turn。所以:
- 每个步骤边界都会取走全部插队消息,它们进入这一步的请求;
- 排队消息只有在新回合开始时才被取走一条,剩下的继续等下一个回合。
同一份文件里还有两条相关行为:
inject()只放入模型可见的上下文,不会唤醒空闲中的执行流程。终端用它处理@会话快照,不用作用户消息的通道。- 取消时,
cancel()默认把两条队列都清空。手动删除和清空会留下带outcome: 'canceled'的 inbox 事件;而步骤边界的取走不带这个标记。终端就是靠这一点区分「被取消」和「已被取走」,详见下文「会话区里的标记」。
你让它重构 X,它开始做:
你:重构 X
├─ 步骤 1 读文件
├─ 步骤 2 跑测试
│ ← 你在这时输入了第二句话
├─ 步骤 3 ……
└─ 步骤 4 收尾,回合结束
| 第二句话怎么发 | 模型什么时候看到 | 结果 |
|---|---|---|
| 排队 | 步骤 4 之后,新回合的第一步 | 它已经按原来的方案改完了,你这时才补充 |
| 插队 | 步骤 2 和步骤 3 之间,和这一步一起 | 它还没开始步骤 3,可以立刻按新要求做 |
简单说:排队是等它做完再说,插队是在它下一步动手之前就告诉它。
| 情况 | 怎么发出 | 会话区里的标记 | 整行底色(以 dark 为例) |
|---|---|---|---|
| 普通消息 | 空闲时直接回车 | 无标记,提示符 ❯ |
亮品牌蓝 |
| 排队 | 回合运行中回车(默认) | ❯ 加行尾的「排队」 |
警示琥珀 |
| 插队 | 空输入框按 Tab 切到插队后回车;或在 /queue 面板里对一条按回车 |
↳ 加行尾的「插队」 |
紫罗兰 |
有一条判断规则需要留意:只有在提交那一刻已经有回合在运行,才记为排队或插队。 空闲时提交的消息一律算普通消息,即使当时选的是插队 —— 没有回合可以插,它本来就会立刻开始一个新回合。
| 位置 | 按键 | 作用 |
|---|---|---|
| 输入框为空 | Tab |
切换下一条消息是排队还是插队 |
| 输入框为空 | Delete |
取消最新一条排队消息 |
| 输入框有内容 | Tab |
仍是命令、技能和 @ 引用的补全 |
/queue 面板 |
↑ ↓ / PageUp / PageDown / g / G |
选择、翻页、跳到首尾 |
/queue 面板 |
Enter |
把选中的排队消息改为插队(只在回合运行中可用) |
/queue 面板 |
e |
修改文字;图片和文件附件原样保留,行尾标为只读 |
/queue 面板 |
d |
从队列里删除 |
| 任意位置 | Esc / Ctrl+C |
取消当前回合:排队消息保留并接着发出,插队消息随这一回合丢掉 |
/queue 面板只管理排队那一队消息。插队属于正在运行的那一回合,所以不在面板里,也无法单独取消。
上游在步骤边界取走消息时,会先写一条普通的 inbox 事件把队列行去掉,之后才写真正的用户消息。所以想标出消息是怎么发来的,不能等到用户消息出现时再去查队列 —— 那时队列行已经没了。
做法是在投影层另外记一份:
- 插入
next-turn、且当时回合在运行 → 记为排队; - 插入
next-step、且当时回合在运行 → 记为插队; - 其余情况不记,按普通消息显示;
- 用户消息出现时,取用这条记录并把它删掉;只有带
canceled的取消事件才提前丢弃。
这份记录随事件流一起重建,所以退出重开、恢复历史会话之后标记仍然在。实时折叠和历史重放走同一套规则,结果由测试保证一致。
三种颜色由每套主题自己提供,不是写死的:
| 主题 | 终端底色 | 普通消息 | 排队 | 插队 |
|---|---|---|---|---|
| dark | 黑 | [103, 158, 254] | [245, 158, 11] | [167, 139, 250] |
| light | 白 | [47, 76, 143] | [124, 53, 10] | [109, 40, 217] |
| prismatic | 黑 | [240, 171, 252] | [245, 158, 11] | [103, 232, 249] |
| rainbow | 黑 | 本次随机出的品牌亮色 | 本次随机出的警示色 | 本次随机出的代码色 |
整行底色是算出来的:把该颜色按 22% 混到主题的终端底色上。所以切换主题或者重新随机 rainbow 之后,底色会跟着一起变。
浅色主题三种颜色都单独加深过。原因是警示色在白底上本身只有 4.5:1,再混一层它自己的浅色底会掉到 3.99:1,所以要另取一组既能压在底色上、也能直接压在白底上的值。
实测对比度(左边是文字压在整行底色上,右边是文字直接压在终端底色上):
| 主题 | 普通消息 | 排队 | 插队 |
|---|---|---|---|
| dark | 5.9 / 7.9 | 7.0 / 9.8 | 5.8 / 7.7 |
| light | 5.8 / 8.2 | 6.1 / 8.9 | 4.9 / 7.1 |
| prismatic | 8.0 / 11.9 | 7.0 / 9.8 | 9.3 / 14.5 |
都满足正文的 AA 要求(不低于 4.5:1)。测试会读出实际生成的底色 RGB 再计算,并检查底色既不同于文字颜色也不同于终端底色、三种颜色互不相同、rainbow 的三种颜色确实取自本次随机结果。
只有 256 色以上的终端才画底色,16 色终端只保留文字颜色 —— 与 /diff 的判断一致,避免饱和的系统色盖住文字。
- 空闲时提交的消息一律显示为普通消息,即使当时选的是插队。
- 按
Esc取消回合时,插队消息会一起丢掉(它属于被取消的那一回合);排队消息会保留并接着发出。 - 一回合结束时如果队列里还有消息,它们会依次作为新的回合继续处理。
/queue面板只认完全匹配的命令:/queue clear这类输入会当作普通文本发给模型。
| 覆盖内容 | 位置 |
|---|---|
| 三种来源的判断,包含「先取走、后写入」的顺序、取消后丢弃、空闲时不标记 | tests/projection.spec.ts |
| 实时折叠与历史重放结果一致 | tests/projection.spec.ts |
| 整行同色、铺满宽度、排队和插队的预览行各自颜色 | tests/lines.spec.ts |
| 提问里粘贴的 ```diff 保留自己的红绿 | tests/lines.spec.ts |
| 主题色的对比度、计算关系、16 色降级 | tests/theme.spec.ts |
按 Tab 之后分别调用 steer 和 dispatch |
tests/app.spec.ts |
| 三种底色确实出现在终端输出里 | tests/app.spec.ts |
面板的 d 删除、删除键不误删、长队列的 g/G 与分页 |
tests/panels.spec.ts |
| 导出时带上来来源标记 | tests/export.spec.ts |